diff --git a/VisualPinball.Engine/Common/Constants.cs b/VisualPinball.Engine/Common/Constants.cs index c003e00d0..19eee6985 100644 --- a/VisualPinball.Engine/Common/Constants.cs +++ b/VisualPinball.Engine/Common/Constants.cs @@ -86,11 +86,15 @@ public static class PhysicsConstants public const float DefaultTableMaxSlope = 6.0f; // DEFAULT_TABLE_MAX_SLOPE public const float DefaultTableGravity = 0.97f; // DEFAULT_TABLE_GRAVITY - public const float GravityConst = 1.81751f; // GRAVITYCONST - - /// - /// trigger/kicker boundary crossing hysterisis - /// + public const float GravityConst = 1.81751f; // GRAVITYCONST + + public const float MToVpu = 50f / (0.0254f * 1.0625f); // MTOVPU + public const float VpuToM = 1f / MToVpu; // VPUTOM + public const float Ms2ToVpuVpt2 = MToVpu * 1e-4f; // MS2TOVPUVPT2 + + /// + /// trigger/kicker boundary crossing hysterisis + /// public const float StaticTime = 0.02f; // STATICTIME public const float StaticCnts = 10f; // STATICCNTS @@ -167,7 +171,11 @@ public static class InputConstants public const string ActionCoinDoorUpDown = "Coin Door Up/Down"; public const string ActionSlamTilt = "Slam Tilt"; public const string ActionSelfTest = "Self Test"; - public const string ActionLeftAdvance = "Left Advance"; - public const string ActionRightAdvance = "Right Advance"; - } -} + public const string ActionLeftAdvance = "Left Advance"; + public const string ActionRightAdvance = "Right Advance"; + public const string ActionLeftNudge = "Left Nudge"; + public const string ActionRightNudge = "Right Nudge"; + public const string ActionCenterNudge = "Center Nudge"; + public const string ActionTilt = "Tilt"; + } +} diff --git a/VisualPinball.Engine/VisualPinball.Engine.csproj b/VisualPinball.Engine/VisualPinball.Engine.csproj index e83eaa1e2..8caa6df91 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.4 + 0.0.5 diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/tilt-bobs.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/tilt-bobs.md new file mode 100644 index 000000000..c26266aa0 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/tilt-bobs.md @@ -0,0 +1,54 @@ +--- +uid: tilt_bobs +title: Tilt Bobs +description: VPE can route tilt switch events from a simulated plumb bob or a real cabinet tilt bob. +--- + +# Tilt Bobs + +A tilt bob is the table's tilt switch route. Add a `TiltBobComponent` when the +table should have a tilt switch that can be driven either by VPE's simulated +plumb bob or by a real cabinet tilt bob wired into the player's cabinet +controller. + +VPX imports add a `TiltBobComponent` to the table root by default. Remove it if +the table should not have a tilt bob route. + +## Setup + +To add a tilt bob manually, select the table object or another object under the +table, click *Add Component*, and select *Pinball -> Mechs -> Tilt Bob*. + +Use the [Switch Manager](xref:switch_manager) to map the game logic tilt switch +to the component's `Tilt Bob` switch item. + +If a table does not have a `TiltBobComponent`, VPE does not simulate a plumb bob +for that table and does not pull the player's physical cabinet tilt switch. This +lets tables opt out by omission instead of by a hidden setting. + +## Source + +The player chooses the plumb source in cabinet/player settings: + +| Source | What happens | +| --- | --- | +| `Simulated` | Cabinet acceleration swings VPE's simulated plumb bob. When it crosses the threshold, the `TiltBobComponent` sends its mapped switch. | +| `Physical` | The player's physical cabinet `Tilt` input drives the `TiltBobComponent`. Use this for a real tilt bob wired into a cabinet controller. | + +This source is a player setting, not a table setting. The table only decides +whether a tilt-bob switch route exists and which game logic switch receives the +signal. + +## Simulated Plumb Bob + +For the simulated source, these `TiltBobComponent` fields shape how easily the +bob trips: + +| Field | Lower value | Higher value | +| --- | --- | --- | +| `Plumb Threshold Angle` | Easier to tilt. | Harder to tilt. | +| `Plumb Damping` | Bob rings longer. | Bob settles faster. | + +Test tilt with the same keyboard and sensor defaults you expect players to use. +Very strong nudging plus a low threshold can make a table tilt too easily. For a +real cabinet bob, test the physical source with the actual cabinet input binding. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/nudging.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/nudging.md new file mode 100644 index 000000000..817bfcd19 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/nudging.md @@ -0,0 +1,126 @@ +--- +uid: nudging +title: Nudging +description: How table authors configure nudge behavior and cabinet input defaults. +--- + +# Nudging + +Nudging in VPE moves the virtual cabinet, not the ball directly. When the player +nudges, VPE computes cabinet acceleration and cabinet offset. The ball reacts to +that acceleration through physics, the camera/table can move visually, and the +table's plumb tilt component can tilt from the same movement when the table +author adds one. + +As a table author, think about nudging in two layers: + +- Table feel: how strong keyboard nudges are, how quickly the cabinet settles, + how much visual motion is shown, and whether the table has a tilt bob route. +- Player hardware: which real device and axes are mapped for a specific + cabinet. + +The first layer belongs in your table defaults. The second layer usually belongs +to the player's cabinet or player app configuration. + +## Table Defaults + +The main table-level nudge settings live on the `PhysicsEngine` component. + +| Setting | What it affects | +| --- | --- | +| `Keyboard Nudge Mode` | How keyboard/button nudges are converted into cabinet motion. | +| `Keyboard Nudge Strength` | Strength multiplier for keyboard/button nudges. | +| `Keyboard Cabinet Damping` | How firmly the cabinet settles after `CabModel` keyboard nudges. | +| `Visual Nudge Strength` | How much of the cabinet offset is shown visually. This is cosmetic. | + +The `Keyboard Nudge Mode` setting has three choices: + +| Mode | What it does | When to use it | +| --- | --- | --- | +| `PushRetract` | Applies the older VP-style push/retract pulse. It gives the ball a sharp kick, then quickly retracts the cabinet offset. | Use it only when a table was tuned around this older keyboard nudge feel. | +| `BoxModel` | Treats the table as a displaced box with a spring pulling it back to rest. It can visibly ring if the timing is long. | Use it when matching legacy VP behavior for a table that depends on that spring-style response. | +| `CabModel` | Applies a short cabinet impulse and lets the cabinet oscillator settle it. This is meant to feel like a sturdy cabinet shove rather than a long bounce. | Use it for new tables and for tables where keyboard nudging should resemble cabinet nudging. | + +The `TableComponent` also has `NudgeTime`, imported from VPX table data. In VPE +this is used by the legacy `BoxModel` keyboard nudge path to set the spring +timing. + +Use `CabModel` for new work unless you are matching an existing table's VP +behavior. It models a cabinet shove and a short return rather than a long visible +spring bounce. `PushRetract` and `BoxModel` are available for legacy VP-style +compatibility. + +Higher keyboard cabinet damping makes the cabinet feel sturdier and settle +faster. Lower damping makes it ring more. If the table visibly bounces too many +times after one keyboard nudge, raise the damping or lower the visual strength. + +## Visual Nudging + +Visual nudge is only presentation. It moves the rendered table/cabinet in +response to the same cabinet offset used by the nudge model, but it does not add +extra physical force. + +Keep visual strength modest. A little movement helps the player feel the nudge; +too much looks like camera shake and can make a physically reasonable nudge feel +wrong. + +## Tilt + +Tilt-bob routing and simulated plumb-bob tuning live on the +[`Tilt Bob`](xref:tilt_bobs) mechanism. + +## Hardware Sensors + +Hardware sensor mapping is handled by the `SimulationThreadComponent` nudge +sensor list and by the player app's cabinet input settings. These mappings are +specific to a cabinet, so avoid baking your personal KL25Z/Pinscape device ID +into a distributable table unless you are intentionally building a cabinet +profile for that setup. + +Sensor types mean: + +| Type | Use it for | +| --- | --- | +| `GamepadIntent` | Gamepad sticks or other analog controls where the player expresses "nudge this way". | +| `CabinetIntent` | A real cabinet sensor where motion should be interpreted as a nudge attempt. | +| `CabinetDirect` | A real cabinet sensor where measured motion should drive cabinet physics directly. | + +Most Xbox-style controllers do not provide accelerometer data for nudging. Map +their sticks as `GamepadIntent`. + +KL25Z/Pinscape boards are cabinet sensors. Map their acceleration axes, then use +mount rotation and mirror to match how the board is installed in the cabinet. +The available rotations are 0, 90, 180, and 270 degrees. Mirror flips the board X +axis before rotation. + +## Editor Calibration + +For direct play in the editor, use the `SimulationThreadComponent` inspector: + +1. Enter Play Mode with the cabinet sensor connected. +2. Use auto-configuration if the first device should be mapped as the cabinet + sensor. +3. Set mount rotation and mirror until graph movement matches cabinet movement. +4. Keep the cabinet still and calibrate sensor centers. +5. Nudge the cabinet lightly and watch the input graph. + +Calibration captures the current resting raw value as zero. It does not remove +spikes, pick better axes, or tune the overall gain. If the graph spikes while the +cabinet is still, check the mapped axes, increase dead zone, reduce scale, or +inspect the physical board/device signal. + +## Shipping Tables + +Ship table defaults that feel good without special hardware. A player should be +able to play with keyboard nudging alone, and cabinet owners should be able to +override sensor mappings from their own configuration. + +Good release checks: + +- Test keyboard nudging with the intended `Keyboard Nudge Mode`. +- Verify visual nudge is noticeable but not distracting. +- If the table has a tilt bob, keep or add a `TiltBobComponent`, map the tilt switch to + it, and verify it can tilt the table without tilting from ordinary play. +- If you include sensor defaults, confirm the table remains playable when those + devices are absent. +- Do not rely on a specific personal device ID for public tables. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index 62dae2c58..71fd85e93 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -95,6 +95,8 @@ href: manual/displays.md - name: Sound href: manual/sound.md + - name: Nudging + href: manual/nudging.md - name: Pinball Mechanisms items: - name: Troughs / Ball Drains @@ -117,7 +119,9 @@ href: manual/mechanisms/score-reels.md - name: Score Motors href: manual/mechanisms/score-motors.md - - name: Collision Switches - href: manual/mechanisms/collision-switches.md - - name: Lifting Gates - href: manual/mechanisms/lifting-gates.md + - name: Collision Switches + href: manual/mechanisms/collision-switches.md + - name: Tilt Bobs + href: manual/mechanisms/tilt-bobs.md + - name: Lifting Gates + href: manual/mechanisms/lifting-gates.md diff --git a/VisualPinball.Unity/Documentation~/developer-guide/index.md b/VisualPinball.Unity/Documentation~/developer-guide/index.md index a46e1e708..263392b02 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/index.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/index.md @@ -63,11 +63,12 @@ This is the shared asset library. It keeps reusable content out of the engine re Use it for reusable art content rather than engine or gameplay code. -## Future integrations - -The developer guide also tracks design work for integrations that are not yet fully implemented in VPE. These pages are intended to capture architectural direction early, so implementation work across native input, runtime systems, and tooling can converge on the same design. +## Runtime architecture and future integrations + +The developer guide also tracks runtime systems and design work for integrations that are not yet fully implemented in VPE. These pages are intended to capture architectural direction early, so implementation work across native input, runtime systems, and tooling can converge on the same design. - [Packaging](xref:developer-guide-packaging-overview) documents the `.vpe` format, the export/import split, the renderer-agnostic material vocabulary, and the benchmarked optimization work around package loading. +- [Nudge System](xref:developer-guide-nudge-system) explains the current keyboard, sensor, visual nudge, and simulated plumb-bob runtime architecture. - [Accelerometer Input Design](xref:developer-guide-accelerometer-input-design) covers analog nudge input, Open Pinball Device support, calibration, and how a future player app should participate in initial setup. - [B2S Integration Design](xref:developer-guide-b2s-integration-design) proposes modernizing the upstream B2S runtime into a shared cross-platform core with a Windows COM shim, a native second-monitor host, and a Unity texture output for VR backglasses. - [DOF Integration Design](xref:developer-guide-dof-integration-design) covers a Windows-first `DirectOutput` integration for the future player app and the later hybrid path toward a `libdof` backend. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/nudge-system.md b/VisualPinball.Unity/Documentation~/developer-guide/nudge-system.md new file mode 100644 index 000000000..b6ab282d0 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/nudge-system.md @@ -0,0 +1,203 @@ +--- +uid: developer-guide-nudge-system +title: Nudge System +description: How VPE combines keyboard nudges, cabinet sensors, visual motion, and tilt-bob routing. +--- + +# Nudge System + +VPE models nudging as cabinet motion. A nudge is not a special force applied +directly to the ball. Instead, the cabinet/playfield reference frame moves and +accelerates, and the ball reacts to that movement through the normal physics +step. This keeps keyboard nudges, analog cabinet devices, visual cabinet motion, +and plumb-bob tilt on the same physical vocabulary. + +The runtime coordinator is `NudgeState`. It owns keyboard nudging, up to four +analog sensor slots, the selected cabinet acceleration for the current physics +tick, the visual cabinet offset, and nudge telemetry. The physics step consumes +`NudgeState.CabinetAcceleration`; rendering consumes `NudgeState.CabinetOffset` +through `VisualNudgeComponent`; simulated tilt uses the same acceleration through +`PlumbState` and routes the resulting switch edges through `TiltBobComponent`. + +Much of the model is a C# port or adaptation of the current VP cabinet nudge +code: + +- `NudgeState` follows the coordinator shape from + `vpinball/src/physics/cabinet/NudgeHandler.*`. +- `KeyboardNudgeState` ports/adapts + `vpinball/src/physics/cabinet/KeyboardNudge.*`. +- `GamepadNudgeState` adapts + `vpinball/src/physics/cabinet/GamepadNudge.*`. +- `CabinetSensorState` adapts + `vpinball/src/physics/cabinet/CabinetNudgeSensor.*`, with support code from + `MotionKalmanAxis.h` and `MotionGainCalibratorAxis.h`. +- `PlumbState` ports/adapts + `vpinball/src/physics/cabinet/PlumbHandler.*`. + +## Data Flow + +```mermaid +flowchart TD + Keys["Keyboard/button nudge
PhysicsEngine.Nudge(angle, force)"] --> Pending["Pending keyboard queue"] + Native["Native analog input
NativeInputManager"] --> Mapper["NudgeSystem
device/axis mapping"] + Mapper --> Samples["Pending sensor sample queue"] + Pending --> Tick["Physics tick
PhysicsEngineThreading"] + Samples --> Tick + Tick --> State["NudgeState
selects one cabinet source"] + State --> Ball["Ball physics
cabinet acceleration"] + State --> Visual["VisualNudgeComponent
cabinet offset"] + State --> Plumb["PlumbState
tilt switch edges"] + Plumb --> TiltBob["TiltBobComponent
authored switch route"] + PhysicalTilt["Physical cabinet Tilt input
real tilt bob"] --> TiltBob + TiltBob --> Switch["Game logic tilt switch"] +``` + +`PhysicsEngine.Nudge(angleDeg, force)` queues a keyboard-style command. In +threaded mode, native axis input is received on the native input polling thread, +matched by `NudgeSystem`, normalized through `SensorMapping`, and queued into the +simulation thread. Both queues are drained at the deterministic physics cadence, +so all nudge sources advance in one millisecond steps. + +Keyboard input intentionally wins while its synthetic cabinet movement is active. +After that, `NudgeState` chooses the most recently active analog sensor. If two +sensors have the same activity timestamp, the stronger cabinet motion wins. This +keeps a deliberate keyboard/button nudge from being diluted by resting sensor +noise, while still allowing multiple cabinet devices to be configured. + +## Intent + +In this system, "intent" means an input expresses what the player wanted to do, +not what the cabinet physically did. + +Keyboard keys, cabinet buttons, and gamepad sticks are intent sources. They say +"nudge left now" or "nudge forward now"; they do not measure a real cabinet +trajectory. VPE converts that request into a synthetic cabinet impulse and then +lets the cabinet spring model ring down. + +Some real accelerometer devices can also be used as intent sources. A +`CabinetIntent` sensor reads cabinet acceleration, detects the nudge attempt, +and feeds a synthesized cabinet impulse. This is useful when a physical sensor is +too noisy, too device-specific, or too hard to scale as direct measured motion, +but still clearly identifies that the player hit the cabinet. + +Direct cabinet sensors are different. A `CabinetDirect` sensor is treated as +measured physical motion. Its velocity and/or acceleration channels are filtered, +scaled, and fed into the cabinet model as the cabinet's actual movement. + +## Source Types + +`KeyboardNudgeMode` controls how digital nudges become cabinet motion: + +| Mode | Purpose | +| --- | --- | +| `PushRetract` | Legacy VP-style instant shove/retract pulse. | +| `BoxModel` | Legacy VP box model where a table-space offset springs back to rest. | +| `CabModel` | Cabinet oscillator model used by the new nudge stack. | + +`NudgeSensorType` controls how an analog slot is interpreted: + +| Type | Channels | Meaning | +| --- | --- | --- | +| `GamepadIntent` | `X`, `Y` | Stick/controller axes as player intent. | +| `CabinetIntent` | `AccelerationX`, `AccelerationY`, optional velocity channels | Measured cabinet acceleration interpreted as a nudge attempt. | +| `CabinetDirect` | `AccelerationX`, `AccelerationY`, optional velocity channels | Measured cabinet motion used directly. | + +Gamepad intent intentionally does not imply that the controller has an +accelerometer. For example, a typical Xbox controller can be mapped through stick +axes, but it should be treated as `GamepadIntent`, not as a cabinet sensor. + +## Sensor Mapping + +Analog mappings are stored as compact strings and parsed into `SensorMapping` +objects at runtime. The string format is used because native device IDs can +contain punctuation and because the same values need to survive player JSON, +table packaging, and editor presets. + +A mapping identifies: + +- Native device ID. +- Native axis ID. +- Axis kind: `Position`, `Velocity`, or `Acceleration`. +- Dead zone. +- Scale. +- Limit. +- Raw neutral center. + +The native input thread emits raw axis values. `SensorMapping.ProcessRawValue()` +subtracts the saved raw center, applies dead zone/scale/limit, and returns the +normalized value queued into physics. + +## Mount Orientation + +Cabinet devices are often installed in different physical orientations. Each +sensor slot therefore has a mount transform: + +- `NudgeSensorMountRotation`: 0, 90, 180, or 270 degrees. +- `MountMirror`: mirror the board X axis before rotation. + +`NudgeSensorState` applies this transform at the slot boundary before dispatching +samples to the gamepad or cabinet implementation. This keeps the rest of the +physics code in cabinet-space X/Y coordinates regardless of how the board is +mounted. + +## Tilt Bob Routing + +`PlumbState` is deliberately only the physics-side bob simulation. It produces +tilt switch edges when cabinet acceleration swings the bob past the configured +threshold, but it does not know which ROM/table switch should receive that +signal. + +`TiltBobComponent` is the table-authored route and owns the simulated bob's +damping and threshold angle. If the table contains this component, the switch +manager can map the game logic tilt switch to the component's `tilt_bob_switch` +item. If the component is absent, VPE disables the physics plumb route for that +table and does not listen to the player's cabinet Tilt input for plumb tilt. + +The source is player/cabinet configuration, not table configuration: + +| Mode | Source | +| --- | --- | +| `Simulated` | `PhysicsEngine` drains `PlumbState` edges and the component forwards them to its mapped switch. | +| `Physical` | Native input or Unity Input System `Tilt` action changes are queued to the component, which forwards the real cabinet bob state to its mapped switch. | + +This separation is important because the table knows where its tilt switch is +routed, while the cabinet owner knows whether tilt should come from simulation +or from a real physical bob. + +## Calibration + +`SimulationThreadComponent.CalibrateNudgeSensorCenters()` captures the current +raw value of every mapped axis and stores it as that mapping's neutral center. +`ResetNudgeSensorCenters()` clears those saved centers. + +This is intentionally center calibration, not gain calibration and not noise +removal. It solves the common case where a resting KL25Z/Pinscape board does not +report exactly zero. It does not choose better axes, remove spikes, or determine +the correct scale. For direct cabinet sensors, `CabinetSensorState` still runs +its physics-side filtering and gain calibration from live motion data. + +Calibration should be performed while the cabinet is at rest and the sensor is +already mapped to the intended axes. + +## Settings + +Cabinet input settings are shared between editor defaults, runtime components, +and the player app: + +| Type | Responsibility | +| --- | --- | +| `CabinetInputSettings` | Native input enablement, polling interval, and nested nudge settings. | +| `CabinetNudgeSettings` | Keyboard mode/strength/damping, visual strength, player tilt-bob source mode, and sensor list. | +| `CabinetNudgeSensorSettings` | Serializable shape for one sensor slot. | +| `CabinetInputSettingsAsset` | Optional editor preset/default wrapper. | +| `TiltBobComponent` | Table-authored switch route plus simulated tilt-bob damping and threshold. | + +`CabinetInputSettings.ApplyTo(tableRoot)` finds the table's `PhysicsEngine` and +`SimulationThreadComponent` and applies the correct subset to each. The player +configuration uses the same `CabinetNudgeSettings` shape, so runtime UI and +editor/direct-play configuration do not need to maintain parallel models. + +Hardware mappings are still considered user/cabinet configuration. A table can +carry defaults for direct editor play or a known cabinet setup, but the built +player should normally write user-specific settings to player configuration +rather than modifying project assets during play. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml index f20b1fcd8..ed89a281b 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml @@ -4,6 +4,8 @@ href: setup.md - name: Threading Model href: threading-model.md +- name: Nudge System + href: nudge-system.md - name: Packaging items: - name: Overview diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs index a215376c2..a143fe887 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs @@ -46,12 +46,13 @@ using VisualPinball.Engine.VPT.Surface; using VisualPinball.Engine.VPT.Table; using VisualPinball.Engine.VPT.TextBox; -using VisualPinball.Engine.VPT.Timer; -using VisualPinball.Engine.VPT.Trigger; -using VisualPinball.Engine.VPT.Trough; -using VisualPinball.Engine.VPT.MetalWireGuide; -using VisualPinball.Unity.Playfield; -using Light = VisualPinball.Engine.VPT.Light.Light; +using VisualPinball.Engine.VPT.Timer; +using VisualPinball.Engine.VPT.Trigger; +using VisualPinball.Engine.VPT.Trough; +using VisualPinball.Engine.VPT.MetalWireGuide; +using VisualPinball.Unity.Playfield; +using VisualPinball.Unity.Simulation; +using Light = VisualPinball.Engine.VPT.Light.Light; using Logger = NLog.Logger; using Material = UnityEngine.Material; using Mesh = UnityEngine.Mesh; @@ -735,11 +736,13 @@ private void CreateRootHierarchy(string tableName = null) _playfieldGo.transform.SetParent(_tableGo.transform, false); backglassGo.transform.SetParent(_tableGo.transform, false); - cabinetGo.transform.SetParent(_tableGo.transform, false); - - // 2. add components - _playfieldGo.AddComponent(); - _playfieldComponent = _playfieldGo.AddComponent(); + cabinetGo.transform.SetParent(_tableGo.transform, false); + + // 2. add components + _tableGo.AddComponent(); + var physicsEngine = _playfieldGo.AddComponent(); + SimulationThreadComponent.EnsureFor(physicsEngine); + _playfieldComponent = _playfieldGo.AddComponent(); _playfieldGo.AddComponent(); _playfieldGo.AddComponent(); _playfieldGo.AddComponent(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs index e6ed34144..b0a3893ac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/PhysicsEngineInspector.cs @@ -23,20 +23,47 @@ namespace VisualPinball.Unity.Editor public class PhysicsEngineInspector : UnityEditor.Editor { private SerializedProperty _gravityProperty; + private SerializedProperty _keyboardNudgeModeProperty; + private SerializedProperty _keyboardNudgeStrengthProperty; + private SerializedProperty _keyboardCabinetDampingProperty; + private SerializedProperty _visualNudgeStrengthProperty; private void OnEnable() { _gravityProperty = serializedObject.FindProperty(nameof(PhysicsEngine.GravityStrength)); + _keyboardNudgeModeProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardNudgeMode)); + _keyboardNudgeStrengthProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardNudgeStrength)); + _keyboardCabinetDampingProperty = serializedObject.FindProperty(nameof(PhysicsEngine.KeyboardCabinetDamping)); + _visualNudgeStrengthProperty = serializedObject.FindProperty(nameof(PhysicsEngine.VisualNudgeStrength)); } public override void OnInspectorGUI() { + serializedObject.Update(); + EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(_gravityProperty, new GUIContent("Gravity Constant")); + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("Keyboard Nudge", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(_keyboardNudgeModeProperty, new GUIContent("Mode")); + EditorGUILayout.PropertyField(_keyboardNudgeStrengthProperty, new GUIContent("Strength")); + EditorGUILayout.PropertyField(_keyboardCabinetDampingProperty, new GUIContent("Cabinet Damping")); + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("Visual Nudge", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(_visualNudgeStrengthProperty, new GUIContent("Strength")); if (EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); + foreach (var obj in targets) { + if (obj is PhysicsEngine physicsEngine) { + physicsEngine.ConfigureKeyboardNudge(physicsEngine.KeyboardNudgeMode, + physicsEngine.KeyboardNudgeStrength, physicsEngine.KeyboardCabinetDamping); + physicsEngine.ConfigureVisualNudge(physicsEngine.VisualNudgeStrength); + } + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs index 244fecd2a..fb6680151 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs @@ -25,6 +25,7 @@ using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; +using VisualPinball.Unity.Simulation; using Logger = NLog.Logger; using Object = UnityEngine.Object; @@ -92,6 +93,7 @@ public async Task ImportIntoScene(string tableName) ReadGlobals(); ReadTableMetadata(); + SimulationThreadComponent.EnsureForTable(_tableRoot.gameObject); RestoreLights(); ImportMaterials(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation.meta new file mode 100644 index 000000000..fc2d215c8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1d8f15be3a5342f19beabc75272f6372 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs new file mode 100644 index 000000000..7324acc25 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs @@ -0,0 +1,558 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity.Editor +{ + /// + /// Custom inspector for simulation-thread settings and native nudge sensor + /// calibration. + /// + /// + /// The inspector reads live native input device snapshots in Play Mode so a + /// cabinet owner can auto-map, center-calibrate, and visually verify KL25Z or + /// Pinscape axes without leaving Unity. + /// + [CustomEditor(typeof(SimulationThreadComponent)), CanEditMultipleObjects] + public sealed class SimulationThreadComponentInspector : UnityEditor.Editor + { + private const int GraphSampleCount = 120; + private const int GraphMaxAxes = 6; + + private static readonly Color[] GraphColors = { + new Color(0.95f, 0.32f, 0.28f), + new Color(0.24f, 0.62f, 1f), + new Color(0.34f, 0.82f, 0.42f), + new Color(1f, 0.72f, 0.2f), + new Color(0.74f, 0.46f, 1f), + new Color(0.25f, 0.86f, 0.8f) + }; + + private readonly Dictionary _axisGraphs = new Dictionary(); + private string _statusMessage; + private MessageType _statusType = MessageType.Info; + private GUIStyle _graphLegendStyle; + private double _lastGraphSampleTime; + + /// + /// Draws default simulation fields plus live nudge calibration controls for + /// single-object selection. + /// + public override void OnInspectorGUI() + { + serializedObject.Update(); + EditorGUI.BeginChangeCheck(); + DrawPropertiesExcluding(serializedObject, "m_Script"); + var changed = EditorGUI.EndChangeCheck(); + serializedObject.ApplyModifiedProperties(); + + if (targets.Length == 1 && target is SimulationThreadComponent component) { + if (changed && Application.isPlaying) { + component.ApplyNudgeSensorSettings(); + } + DrawNudgeCalibration(component); + } + } + + /// + /// Requests repaint while Play Mode graphs can receive fresh input samples. + /// + public override bool RequiresConstantRepaint() + { + return Application.isPlaying && targets.Length == 1; + } + + /// + /// Draws Play Mode controls for device listing, auto-mapping, and center + /// calibration. + /// + private void DrawNudgeCalibration(SimulationThreadComponent component) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Nudge Calibration", EditorStyles.boldLabel); + + if (!Application.isPlaying) { + EditorGUILayout.HelpBox("Enter Play Mode to read native input devices and calibrate the current sensor centers.", MessageType.Info); + return; + } + + EditorGUILayout.LabelField("Simulation", component.IsRunning ? "Running" : "Stopped"); + EditorGUILayout.LabelField("Input Thread", $"{component.InputThreadActualHz:0.#} Hz / target {component.InputThreadTargetHz:0.#} Hz"); + + var devices = component.ListNudgeInputDevices(); + if (devices.Count == 0) { + EditorGUILayout.HelpBox("No native input devices are visible. Check that native input is enabled and that the device was connected before polling started.", MessageType.Warning); + } else { + DrawDevices(devices); + DrawInputGraph(component, devices); + } + + EditorGUILayout.Space(); + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginDisabledGroup(devices.Count == 0); + if (GUILayout.Button("Auto-map First Device")) { + Undo.RecordObject(component, "Auto-map nudge sensor"); + if (component.TryAutoConfigureFirstCabinetSensor(out var message)) { + SetStatus(message, MessageType.Info); + EditorUtility.SetDirty(component); + } else { + SetStatus(message, MessageType.Warning); + } + } + EditorGUI.EndDisabledGroup(); + + EditorGUI.BeginDisabledGroup(devices.Count == 0 || component.NudgeSensors == null || component.NudgeSensors.Count == 0); + if (GUILayout.Button("Calibrate Centers")) { + Undo.RecordObject(component, "Calibrate nudge sensor centers"); + var calibrated = component.CalibrateNudgeSensorCenters(); + if (calibrated > 0) { + SetStatus($"Calibrated {calibrated} mapped nudge channel(s).", MessageType.Info); + EditorUtility.SetDirty(component); + } else { + SetStatus("No mapped channels matched the currently visible devices.", MessageType.Warning); + } + } + EditorGUI.EndDisabledGroup(); + EditorGUILayout.EndHorizontal(); + + EditorGUI.BeginDisabledGroup(component.NudgeSensors == null || component.NudgeSensors.Count == 0); + if (GUILayout.Button("Reset Centers")) { + Undo.RecordObject(component, "Reset nudge sensor centers"); + var reset = component.ResetNudgeSensorCenters(); + SetStatus(reset > 0 ? "Cleared nudge sensor raw centers." : "No nudge sensors to reset.", MessageType.Info); + EditorUtility.SetDirty(component); + } + EditorGUI.EndDisabledGroup(); + + if (!string.IsNullOrEmpty(_statusMessage)) { + EditorGUILayout.HelpBox(_statusMessage, _statusType); + } + } + + /// + /// Lists visible native input devices and their first few axes. + /// + private void DrawDevices(IReadOnlyList devices) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Native Devices", EditorStyles.boldLabel); + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + EditorGUILayout.LabelField(DeviceLabel(device), EditorStyles.miniBoldLabel); + if (device.Axes == null || device.Axes.Count == 0) { + EditorGUILayout.LabelField(" No axes"); + continue; + } + var axisCount = System.Math.Min(device.Axes.Count, 6); + for (var j = 0; j < axisCount; j++) { + var axis = device.Axes[j]; + EditorGUILayout.LabelField($" {AxisLabel(axis)}", EditorStyles.miniLabel); + } + if (device.Axes.Count > axisCount) { + EditorGUILayout.LabelField($" ... {device.Axes.Count - axisCount} more", EditorStyles.miniLabel); + } + } + } + + /// + /// Draws a rolling graph of mapped nudge channels, or raw axes when no + /// mappings exist yet. + /// + private void DrawInputGraph(SimulationThreadComponent component, IReadOnlyList devices) + { + var channels = new List(); + var title = "Nudge Graph: Mounted Mappings"; + CollectMappedGraphChannels(component, devices, channels); + if (channels.Count == 0) { + if (!TryFindGraphDevice(devices, out var device)) { + return; + } + title = $"Raw Input Graph: {DeviceName(device)}"; + CollectRawGraphChannels(device, channels); + } + if (channels.Count == 0) { + return; + } + + SampleGraphChannels(channels); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField(title, EditorStyles.boldLabel); + + var rect = GUILayoutUtility.GetRect(1f, 72f, GUILayout.ExpandWidth(true)); + DrawGraphFrame(rect); + + for (var i = 0; i < channels.Count; i++) { + if (_axisGraphs.TryGetValue(channels[i].Key, out var graph)) { + DrawGraphLine(rect, graph, GraphColors[i % GraphColors.Length]); + } + } + + DrawGraphLegend(channels); + } + + /// + /// Samples graph channels at editor repaint cadence with a 60 Hz cap. + /// + private void SampleGraphChannels(IReadOnlyList channels) + { + var now = EditorApplication.timeSinceStartup; + if (now - _lastGraphSampleTime < 1.0 / 60.0) { + return; + } + _lastGraphSampleTime = now; + + for (var i = 0; i < channels.Count; i++) { + var channel = channels[i]; + if (!_axisGraphs.TryGetValue(channel.Key, out var graph)) { + graph = new AxisGraphState(); + _axisGraphs[channel.Key] = graph; + } + graph.Add(channel.Value); + } + } + + /// + /// Draws the graph background and reference lines. + /// + private void DrawGraphFrame(Rect rect) + { + EditorGUI.DrawRect(rect, new Color(0.1f, 0.1f, 0.1f)); + + Handles.BeginGUI(); + var previousColor = Handles.color; + Handles.color = new Color(1f, 1f, 1f, 0.18f); + Handles.DrawLine(new Vector3(rect.xMin, rect.center.y), new Vector3(rect.xMax, rect.center.y)); + Handles.color = new Color(1f, 1f, 1f, 0.08f); + Handles.DrawLine(new Vector3(rect.xMin, rect.yMin + rect.height * 0.25f), new Vector3(rect.xMax, rect.yMin + rect.height * 0.25f)); + Handles.DrawLine(new Vector3(rect.xMin, rect.yMin + rect.height * 0.75f), new Vector3(rect.xMax, rect.yMin + rect.height * 0.75f)); + Handles.color = previousColor; + Handles.EndGUI(); + } + + /// + /// Draws one channel as a polyline in the graph rectangle. + /// + private static void DrawGraphLine(Rect rect, AxisGraphState graph, Color color) + { + if (graph.Count < 2) { + return; + } + + Handles.BeginGUI(); + var previousColor = Handles.color; + Handles.color = color; + var previous = GraphPoint(rect, graph, 0); + for (var i = 1; i < graph.Count; i++) { + var next = GraphPoint(rect, graph, i); + Handles.DrawLine(previous, next); + previous = next; + } + Handles.color = previousColor; + Handles.EndGUI(); + } + + /// + /// Converts a sample index/value into graph GUI coordinates. + /// + private static Vector3 GraphPoint(Rect rect, AxisGraphState graph, int index) + { + var x = graph.Count <= 1 ? rect.xMin : Mathf.Lerp(rect.xMin, rect.xMax, index / (float)(graph.Count - 1)); + var value = Mathf.Clamp(graph.Get(index), -1f, 1f); + var y = rect.center.y - value * rect.height * 0.45f; + return new Vector3(x, y); + } + + /// + /// Draws the current value legend below the graph. + /// + private void DrawGraphLegend(IReadOnlyList channels) + { + if (_graphLegendStyle == null) { + _graphLegendStyle = new GUIStyle(EditorStyles.miniLabel) { + richText = true, + wordWrap = false + }; + } + + EditorGUILayout.BeginHorizontal(); + for (var i = 0; i < channels.Count; i++) { + var color = GraphColors[i % GraphColors.Length]; + var colorHex = ColorUtility.ToHtmlStringRGB(color); + GUILayout.Label($"{channels[i].Label} {channels[i].Value:+0.00;-0.00;0.00}", _graphLegendStyle); + } + EditorGUILayout.EndHorizontal(); + } + + /// + /// Collects live graph channels from configured nudge mappings. + /// + private static void CollectMappedGraphChannels(SimulationThreadComponent component, + IReadOnlyList devices, List channels) + { + if (component.NudgeSensors == null) { + return; + } + + for (var sensorIndex = 0; sensorIndex < component.NudgeSensors.Count && channels.Count < GraphMaxAxes; sensorIndex++) { + var sensor = component.NudgeSensors[sensorIndex]; + if (sensor == null) { + continue; + } + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.X, sensor.X, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.Y, sensor.Y, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.VelocityX, sensor.VelocityX, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.VelocityY, sensor.VelocityY, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.AccelerationX, sensor.AccelerationX, devices, channels); + AddMappedGraphChannel(sensorIndex, sensor, NudgeSensorChannel.AccelerationY, sensor.AccelerationY, devices, channels); + } + } + + /// + /// Adds one mapped channel after applying the same mount transform used by + /// physics. + /// + private static void AddMappedGraphChannel(int sensorIndex, SimulationThreadNudgeSensorConfig sensor, + NudgeSensorChannel sourceChannel, string mappingValue, + IReadOnlyList devices, List channels) + { + if (channels.Count >= GraphMaxAxes || !SensorMapping.TryParse(mappingValue, out var mapping)) { + return; + } + if (!TryFindAxis(devices, mapping.DeviceId, mapping.AxisId, out var axis)) { + return; + } + + var channel = sourceChannel; + var value = CalculateMappedGraphValue(mapping, axis.RawValue); + NudgeSensorMountTransform.TransformChannel(ref channel, ref value, sensor.MountRotation, sensor.MountMirror); + + channels.Add(new InputGraphChannel( + $"mapped:{sensorIndex}:{sourceChannel}:{channel}:{mapping.DeviceId}:{mapping.AxisId}:{sensor.MountRotation}:{sensor.MountMirror}", + $"{ChannelName(channel)} ({AxisName(axis)})", + value)); + } + + /// + /// Adds the first few raw axes from a device for graphing before mappings + /// exist. + /// + private static void CollectRawGraphChannels(NativeInputDeviceInfo device, List channels) + { + var axisCount = System.Math.Min(device.Axes.Count, GraphMaxAxes); + for (var i = 0; i < axisCount; i++) { + var axis = device.Axes[i]; + channels.Add(new InputGraphChannel( + GraphKey(device, axis), + AxisName(axis), + axis.RawValue)); + } + } + + /// + /// Applies mapping center/dead-zone/limit to a raw value for graph display. + /// + /// + /// Scale is intentionally omitted so the graph shows centered normalized + /// input direction and noise, not the physics strength applied later. + /// + private static float CalculateMappedGraphValue(SensorMapping mapping, float rawValue) + { + var value = rawValue - mapping.RawCenter; + var deadZone = Mathf.Clamp(mapping.DeadZone, 0f, 0.999f); + var absValue = Mathf.Abs(value); + if (absValue <= deadZone) { + return 0f; + } + value = Mathf.Sign(value) * ((absValue - deadZone) / (1f - deadZone)); + var limit = Mathf.Max(0f, mapping.Limit); + return Mathf.Clamp(value, -limit, limit); + } + + /// + /// Finds the live axis snapshot for a serialized mapping. + /// + private static bool TryFindAxis(IReadOnlyList devices, string deviceId, int axisId, + out NativeInputAxisInfo axis) + { + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + if (device.Id != deviceId || device.Axes == null) { + continue; + } + for (var j = 0; j < device.Axes.Count; j++) { + if (device.Axes[j].AxisId == axisId) { + axis = device.Axes[j]; + return true; + } + } + } + + axis = default; + return false; + } + + /// + /// Chooses the device to graph when no channels are mapped yet. + /// + private static bool TryFindGraphDevice(IReadOnlyList devices, out NativeInputDeviceInfo device) + { + for (var i = 0; i < devices.Count; i++) { + if (IsGraphableDevice(devices[i]) && IsKl25zDevice(devices[i])) { + device = devices[i]; + return true; + } + } + for (var i = 0; i < devices.Count; i++) { + if (IsGraphableDevice(devices[i])) { + device = devices[i]; + return true; + } + } + + device = default; + return false; + } + + /// + /// Returns whether a device has connected axes suitable for graphing. + /// + private static bool IsGraphableDevice(NativeInputDeviceInfo device) + { + return device.IsConnected && device.Axes != null && device.Axes.Count > 0; + } + + /// + /// Prefers likely KL25Z/Pinscape devices for the raw graph. + /// + private static bool IsKl25zDevice(NativeInputDeviceInfo device) + { + return ContainsIgnoreCase(device.Name, "KL25Z") + || ContainsIgnoreCase(device.Id, "KL25Z") + || ContainsIgnoreCase(device.Name, "Pinscape") + || ContainsIgnoreCase(device.Id, "Pinscape"); + } + + private static bool ContainsIgnoreCase(string value, string match) + { + return !string.IsNullOrEmpty(value) && value.IndexOf(match, StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Builds a stable key for one raw graph channel. + /// + private static string GraphKey(NativeInputDeviceInfo device, NativeInputAxisInfo axis) + { + return $"{device.Id}:{axis.AxisId}"; + } + + private static string DeviceLabel(NativeInputDeviceInfo device) + { + var state = device.IsConnected ? "connected" : "disconnected"; + return $"{DeviceName(device)} ({state})"; + } + + private static string DeviceName(NativeInputDeviceInfo device) + { + return string.IsNullOrEmpty(device.Name) ? device.Id : device.Name; + } + + private static string AxisLabel(NativeInputAxisInfo axis) + { + return $"{AxisName(axis)}: {axis.RawValue:+0.000;-0.000;0.000} [{axis.Kind}]"; + } + + private static string ChannelName(NudgeSensorChannel channel) + { + return channel switch { + NudgeSensorChannel.VelocityX => "Velocity X", + NudgeSensorChannel.VelocityY => "Velocity Y", + NudgeSensorChannel.AccelerationX => "Accel X", + NudgeSensorChannel.AccelerationY => "Accel Y", + NudgeSensorChannel.Y => "Y", + _ => "X" + }; + } + + private static string AxisName(NativeInputAxisInfo axis) + { + return string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; + } + + private void SetStatus(string message, MessageType type) + { + _statusMessage = message; + _statusType = type; + } + + /// + /// Stores one graph channel sample and label. + /// + private sealed class InputGraphChannel + { + public readonly string Key; + public readonly string Label; + public readonly float Value; + + /// + /// Creates a graph channel snapshot for the current repaint. + /// + public InputGraphChannel(string key, string label, float value) + { + Key = key; + Label = label; + Value = value; + } + } + + /// + /// Fixed-size rolling sample buffer for one graph line. + /// + private sealed class AxisGraphState + { + private readonly float[] _samples = new float[GraphSampleCount]; + private int _next; + + public int Count { get; private set; } + + /// + /// Appends one sample, overwriting the oldest sample when full. + /// + public void Add(float value) + { + _samples[_next] = value; + _next = (_next + 1) % _samples.Length; + if (Count < _samples.Length) { + Count++; + } + } + + /// + /// Reads a sample by age, where zero is the oldest retained sample. + /// + public float Get(int index) + { + var start = (_next - Count + _samples.Length) % _samples.Length; + return _samples[(start + index) % _samples.Length]; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs.meta new file mode 100644 index 000000000..82ec3d555 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationThreadComponentInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c9ea6811636342178d3736473a562b3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs index f20b324af..4f6b2454c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs @@ -32,6 +32,7 @@ public class TableInspector : ItemInspector protected override MonoBehaviour UndoTarget => target as MonoBehaviour; private SerializedProperty _globalDifficultyProperty; + private SerializedProperty _nudgeTimeProperty; private SerializedProperty _metadataProperty; private bool _packageFoldout; private bool _runtimeCompressSideChannelTextures = true; @@ -54,6 +55,7 @@ protected override void OnEnable() base.OnEnable(); _globalDifficultyProperty = serializedObject.FindProperty(nameof(TableComponent.GlobalDifficulty)); + _nudgeTimeProperty = serializedObject.FindProperty(nameof(TableComponent.NudgeTime)); _metadataProperty = serializedObject.FindProperty(nameof(TableComponent.Metadata)); _runtimeCompressSideChannelTextures = EditorPrefs.GetBool(RuntimeCompressSideChannelTexturesKey, true); _runtimeCompressNormalMaps = EditorPrefs.GetBool(RuntimeCompressNormalMapsKey, true); @@ -73,6 +75,7 @@ public override void OnInspectorGUI() BeginEditing(); PropertyField(_globalDifficultyProperty); + PropertyField(_nudgeTimeProperty); EditorGUILayout.Space(); _packageFoldout = EditorGUILayout.Foldout(_packageFoldout, "Package", true); @@ -178,4 +181,4 @@ public override void OnInspectorGUI() } } } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob.meta new file mode 100644 index 000000000..27ef399e2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 78d4155d1b004d119ececd4fa5a4cefe +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs new file mode 100644 index 000000000..e935f15e3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs @@ -0,0 +1,52 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(TiltBobComponent)), CanEditMultipleObjects] + public class TiltBobInspector : ItemInspector + { + private SerializedProperty _plumbDampingProperty; + private SerializedProperty _plumbThresholdAngleProperty; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + + _plumbDampingProperty = serializedObject.FindProperty(nameof(TiltBobComponent.PlumbDamping)); + _plumbThresholdAngleProperty = serializedObject.FindProperty(nameof(TiltBobComponent.PlumbThresholdAngle)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + + OnPreInspectorGUI(); + + PropertyField(_plumbDampingProperty, "Plumb Damping"); + PropertyField(_plumbThresholdAngleProperty, "Plumb Threshold Angle"); + + base.OnInspectorGUI(); + + EndEditing(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs.meta new file mode 100644 index 000000000..d9d3b65b4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/TiltBob/TiltBobInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72d5236734db419c8ff4c74afc040dc5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Input.meta new file mode 100644 index 000000000..b0a643b43 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5b58f9e76ac64d0c90f4c18399bf1f14 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs new file mode 100644 index 000000000..33620cb15 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs @@ -0,0 +1,132 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + /// + /// Covers serialized analog-axis mappings used by nudge sensor configuration. + /// + public class SensorMappingTests + { + [Test] + public void MappingRoundTripsUsingVpinballFormat() + { + var mapping = new SensorMapping { + DeviceId = "HID\\VID_1209&PID_EAEA", + AxisId = 3, + Kind = SensorMappingKind.Velocity, + DeadZone = 0.05f, + Scale = -2f, + Limit = 0.75f + }; + + Assert.That(SensorMapping.TryParse(mapping.ToString(), out var parsed), Is.True); + Assert.That(parsed.DeviceId, Is.EqualTo(mapping.DeviceId)); + Assert.That(parsed.AxisId, Is.EqualTo(mapping.AxisId)); + Assert.That(parsed.Kind, Is.EqualTo(mapping.Kind)); + Assert.That(parsed.DeadZone, Is.EqualTo(mapping.DeadZone).Within(0.0001f)); + Assert.That(parsed.Scale, Is.EqualTo(mapping.Scale).Within(0.0001f)); + Assert.That(parsed.Limit, Is.EqualTo(mapping.Limit).Within(0.0001f)); + } + + [Test] + public void MappingRoundTripsOptionalRawCenter() + { + var mapping = new SensorMapping { + DeviceId = "HID\\VID_1209&PID_EAEA", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0.02f, + Scale = 9.81f, + Limit = 1f, + RawCenter = 512f + }; + + Assert.That(SensorMapping.TryParse(mapping.ToString(), out var parsed), Is.True); + Assert.That(parsed.RawCenter, Is.EqualTo(mapping.RawCenter).Within(0.0001f)); + } + + [Test] + public void MappingRoundTripsWithSemicolonInDeviceId() + { + var mapping = new SensorMapping { + DeviceId = @"\\?\hid#vid_1209&pid_eaea;col02#8&2f&0&1", + AxisId = 0, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0f, + Scale = 9.81f, + Limit = 1f + }; + + Assert.That(SensorMapping.TryParse(mapping.ToString(), out var parsed), Is.True); + Assert.That(parsed.DeviceId, Is.EqualTo(mapping.DeviceId)); + Assert.That(parsed.AxisId, Is.EqualTo(mapping.AxisId)); + } + + [Test] + public void ProcessingAppliesDeadZoneLimitAndScale() + { + var mapping = new SensorMapping { + DeviceId = "device", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0.1f, + Scale = 10f, + Limit = 0.5f + }; + + Assert.That(mapping.ProcessRawValue(0.05f, 100), Is.EqualTo(0f)); + Assert.That(mapping.ProcessRawValue(1f, 200), Is.EqualTo(5f)); + Assert.That(mapping.RawTimestampUsec, Is.EqualTo(200)); + } + + [Test] + public void ProcessingSubtractsRawCenterBeforeDeadZone() + { + var mapping = new SensorMapping { + DeviceId = "device", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0.05f, + Scale = 10f, + Limit = 1f, + RawCenter = 0.2f + }; + + Assert.That(mapping.ProcessRawValue(0.22f, 100), Is.EqualTo(0f)); + Assert.That(mapping.ProcessRawValue(0.65f, 200), Is.GreaterThan(0f)); + } + + [Test] + public void ProcessingSubtractsUnclampedRawCenter() + { + var mapping = new SensorMapping { + DeviceId = "device", + AxisId = 1, + Kind = SensorMappingKind.Acceleration, + DeadZone = 0f, + Scale = 1f, + Limit = 10f, + RawCenter = 512f + }; + + Assert.That(mapping.ProcessRawValue(512f, 100), Is.EqualTo(0f)); + Assert.That(mapping.ProcessRawValue(514f, 200), Is.EqualTo(2f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs.meta new file mode 100644 index 000000000..1c50467de --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Input/SensorMappingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f98f3bc7a12449d9a74e470d4b7df4f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics.meta new file mode 100644 index 000000000..9ebe585bb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 708e2e5ed6414d60aa87fd0eeab4083a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs new file mode 100644 index 000000000..baf74aa6b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs @@ -0,0 +1,140 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + /// + /// Covers cabinet spring, keyboard nudge, and plumb-bob physics primitives. + /// + public class CabinetPhysicsTests + { + [Test] + public void OscillatorTracksAnalyticStepResponse() + { + const float mass = 113f; + const float frequencyHz = 9.3f; + const float dampingRatio = 0.052f; + const float targetAcceleration = 12f; + var oscillator = new DampedHarmonicOscillator(mass, frequencyHz, dampingRatio); + var force = mass * targetAcceleration; + + for (var i = 0; i < 50; i++) { + oscillator.StepOneMillisecond(force); + } + + var expected = AnalyticStepResponse(mass, frequencyHz, dampingRatio, force, 0.050f); + Assert.That(oscillator.Displacement, Is.EqualTo(expected.Displacement).Within(0.0008f)); + Assert.That(oscillator.Velocity, Is.EqualTo(expected.Velocity).Within(0.03f)); + Assert.That(oscillator.Acceleration, Is.EqualTo(expected.Acceleration).Within(0.7f)); + } + + [Test] + public void CabinetPhysicsUsesCalibratedAxisModels() + { + var cabinet = CabinetPhysicsState.Default; + cabinet.StepOneMillisecond(new float2(cabinet.Mass * 12f, cabinet.Mass * -10f)); + + Assert.That(cabinet.CabinetAcceleration.x, Is.EqualTo(12f).Within(1e-5f)); + Assert.That(cabinet.CabinetAcceleration.y, Is.EqualTo(-10f).Within(1e-5f)); + Assert.That(cabinet.CabinetOffset.x, Is.GreaterThan(0f)); + Assert.That(cabinet.CabinetOffset.y, Is.LessThan(0f)); + } + + [Test] + public void KeyboardCabinetModelLimitsVisibleRebounds() + { + var nudge = new KeyboardNudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.Nudge(0f, 2f); + + var offsets = new float[1000]; + var peak = 0f; + for (var i = 0; i < offsets.Length; i++) { + nudge.StepOneMillisecond(); + offsets[i] = nudge.CabinetOffset.y; + peak = math.max(peak, math.abs(offsets[i])); + } + + Assert.That(CountVisibleExtrema(offsets, peak * 0.1f), Is.LessThanOrEqualTo(3)); + } + + [Test] + public void PlumbTiltLatchesAndQueuesTiltEvent() + { + var plumb = new PlumbState(true, 1f, 2f); + + for (var i = 0; i < 1000 && !plumb.TiltHigh; i++) { + plumb.StepOneMillisecond(new float2(80f, 0f)); + } + + Assert.That(plumb.TiltHigh, Is.True); + Assert.That(plumb.TiltIndex, Is.EqualTo(1)); + Assert.That(plumb.PendingTiltStates.Length, Is.EqualTo(1)); + Assert.That(plumb.PendingTiltStates[0], Is.EqualTo(1)); + + var status = plumb.ReadAndResetTiltStatus(); + Assert.That(math.abs(status.x), Is.GreaterThan(0f)); + Assert.That(status.z, Is.GreaterThan(100f)); + + var resetStatus = plumb.ReadAndResetTiltStatus(); + Assert.That(resetStatus.x, Is.EqualTo(0f)); + Assert.That(resetStatus.y, Is.EqualTo(0f)); + Assert.That(resetStatus.z, Is.EqualTo(0f)); + + plumb.ClearPendingTiltEvents(); + Assert.That(plumb.PendingTiltStates.Length, Is.EqualTo(0)); + } + + /// + /// Computes the closed-form underdamped oscillator response used to verify + /// the one-millisecond integrator. + /// + private static (float Displacement, float Velocity, float Acceleration) AnalyticStepResponse(float mass, float frequencyHz, float dampingRatio, float force, float time) + { + var omega0 = 2f * math.PI * frequencyHz; + var omegaD = omega0 * math.sqrt(1f - dampingRatio * dampingRatio); + var alpha = dampingRatio * omega0; + var steadyState = force / (mass * omega0 * omega0); + var decay = math.exp(-alpha * time); + var displacement = steadyState * (1f - decay * (math.cos(omegaD * time) + alpha / omegaD * math.sin(omegaD * time))); + var velocity = steadyState * decay * (omega0 * omega0 / omegaD) * math.sin(omegaD * time); + var acceleration = (force - 2f * dampingRatio * mass * omega0 * velocity - mass * omega0 * omega0 * displacement) / mass; + + return (displacement, velocity, acceleration); + } + + /// + /// Counts visible rebound peaks after ignoring small tail oscillations. + /// + private static int CountVisibleExtrema(float[] values, float threshold) + { + var count = 0; + for (var i = 2; i < values.Length; i++) { + var previousSlope = values[i - 1] - values[i - 2]; + var slope = values[i] - values[i - 1]; + if (math.abs(values[i - 1]) <= threshold || previousSlope == 0f || slope == 0f) { + continue; + } + if (math.sign(previousSlope) != math.sign(slope)) { + count++; + } + } + return count; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs.meta new file mode 100644 index 000000000..290ca419f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/CabinetPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a825506318714722902efe0b72b52afe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs new file mode 100644 index 000000000..80f724382 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs @@ -0,0 +1,265 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + /// + /// Covers analog nudge sensor filtering, calibration, mounting transforms, + /// and source selection. + /// + public class NudgeSensorStateTests + { + [Test] + public void KalmanRestConstraintsDrainConstantBias() + { + var axis = MotionKalmanAxis.Default; + for (ulong t = 1000; t <= 500000; t += 1000) { + axis.UpdateAcceleration(t, 0.3f); + axis.UpdateRestConstraints(t); + } + + Assert.That(math.abs(axis.Acceleration), Is.LessThan(0.02f)); + Assert.That(math.abs(axis.Velocity), Is.LessThan(0.01f)); + } + + [Test] + public void GainCalibratorConvergesAcrossMotionSegments() + { + var calibrator = MotionGainCalibratorAxis.Default; + const float expectedGain = 2.0f; + ulong baseTime = 0; + + for (var segment = 0; segment < 30; segment++) { + calibrator.StartSegment(baseTime); + var velocity = 0f; + var previousAcceleration = 0f; + for (var i = 0; i <= 100; i++) { + var t = i / 100f; + var acceleration = math.sin(2f * math.PI * t); + if (i > 0) { + velocity += 0.5f * (previousAcceleration + acceleration) * 0.001f; + } + calibrator.AddSample(baseTime + (ulong)(i * 1000), expectedGain * velocity, acceleration); + previousAcceleration = acceleration; + } + calibrator.EndSegment(); + baseTime += 150000; + } + + Assert.That(calibrator.Gain, Is.EqualTo(expectedGain).Within(0.05f)); + Assert.That(calibrator.GlobalConfidence, Is.GreaterThanOrEqualTo(0.5f)); + } + + [Test] + public void IntentHandlerFiresSingleImpulseForPeak() + { + var intent = new NudgeIntentState(false); + + intent.StepOneMillisecond(new float2(0f, 0f)); + intent.StepOneMillisecond(new float2(0f, -0.5f)); + intent.StepOneMillisecond(new float2(0f, -1.2f)); + + Assert.That(intent.IsImpulseInProgress, Is.True); + intent.StepOneMillisecond(new float2(0f, -1.4f)); + Assert.That(intent.ImpulseAcceleration.y, Is.LessThan(0f)); + + // Ride out the rest of the peak and its decay: no second impulse may + // fire for the same peak (25ms impulse length, then the signal decays). + var impulseTicks = 0; + for (var i = 0; i < 200; i++) { + var signal = -1.4f * math.exp(-i / 30f); + intent.StepOneMillisecond(new float2(0f, signal)); + if (intent.IsImpulseInProgress) { + impulseTicks++; + } + } + Assert.That(impulseTicks, Is.LessThanOrEqualTo(25)); + } + + [Test] + public void GainCalibratorSurvivesLongSegmentsByCompacting() + { + var calibrator = MotionGainCalibratorAxis.Default; + const float expectedGain = 2.0f; + ulong baseTime = 0; + + // 1.5s segments at 1kHz: way past the sample buffer capacity, forcing + // resolution compaction. The segment must stay whole (rest-to-rest), so + // the gain still converges and no segment splitting inflates confidence. + for (var segment = 0; segment < 10; segment++) { + calibrator.StartSegment(baseTime); + var velocity = 0f; + var previousAcceleration = 0f; + for (var i = 0; i <= 1500; i++) { + var t = i / 1500f; + var acceleration = math.sin(2f * math.PI * 9f * t) * math.sin(math.PI * t); + if (i > 0) { + velocity += 0.5f * (previousAcceleration + acceleration) * 0.001f; + } + calibrator.AddSample(baseTime + (ulong)(i * 1000), expectedGain * velocity, acceleration); + previousAcceleration = acceleration; + } + calibrator.EndSegment(); + baseTime += 2000000; + } + + Assert.That(calibrator.AcceptedSegmentCount, Is.LessThanOrEqualTo(10)); + Assert.That(calibrator.Gain, Is.EqualTo(expectedGain).Within(0.15f)); + } + + [TestCase(NudgeSensorMountRotation.Rotation0, false, NudgeSensorChannel.AccelerationX, 2f, NudgeSensorChannel.AccelerationX, 2f)] + [TestCase(NudgeSensorMountRotation.Rotation90, false, NudgeSensorChannel.AccelerationX, 2f, NudgeSensorChannel.AccelerationY, 2f)] + [TestCase(NudgeSensorMountRotation.Rotation90, false, NudgeSensorChannel.AccelerationY, 2f, NudgeSensorChannel.AccelerationX, -2f)] + [TestCase(NudgeSensorMountRotation.Rotation180, false, NudgeSensorChannel.VelocityX, 2f, NudgeSensorChannel.VelocityX, -2f)] + [TestCase(NudgeSensorMountRotation.Rotation270, false, NudgeSensorChannel.X, 2f, NudgeSensorChannel.Y, -2f)] + [TestCase(NudgeSensorMountRotation.Rotation90, true, NudgeSensorChannel.AccelerationX, 2f, NudgeSensorChannel.AccelerationY, -2f)] + public void MountTransformRotatesAndMirrorsChannels(NudgeSensorMountRotation rotation, bool mirror, + NudgeSensorChannel sourceChannel, float sourceValue, NudgeSensorChannel expectedChannel, float expectedValue) + { + var channel = sourceChannel; + var value = sourceValue; + + NudgeSensorMountTransform.TransformChannel(ref channel, ref value, rotation, mirror); + + Assert.That(channel, Is.EqualTo(expectedChannel)); + Assert.That(value, Is.EqualTo(expectedValue).Within(1e-6f)); + } + + [Test] + public void MountTransformRotatesMappedFlags() + { + var xMapped = true; + var yMapped = false; + + NudgeSensorMountTransform.TransformMappedAxes(ref xMapped, ref yMapped, NudgeSensorMountRotation.Rotation90); + + Assert.That(xMapped, Is.False); + Assert.That(yMapped, Is.True); + } + + [Test] + public void DirectCabinetSensorCanDriveNudgeState() + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, 0f, 1000); + nudge.StepOneMillisecond(); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, -12f, 2000); + for (var i = 0; i < 10; i++) { + nudge.StepOneMillisecond(); + } + + Assert.That(nudge.ActiveSourceIndex, Is.EqualTo(0)); + Assert.That(nudge.CabinetAcceleration.y, Is.LessThan(0f)); + Assert.That(math.abs(nudge.CabinetOffset.y), Is.GreaterThan(0f)); + } + + [Test] + public void DirectCabinetSensorAppliesMountTransform() + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + MountRotation = NudgeSensorMountRotation.Rotation90, + MountMirror = 1, + AccelerationXMapped = 1 + }); + + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationX, 0f, 1000); + nudge.StepOneMillisecond(); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationX, 12f, 2000); + for (var i = 0; i < 10; i++) { + nudge.StepOneMillisecond(); + } + + Assert.That(nudge.ActiveSourceIndex, Is.EqualTo(0)); + Assert.That(nudge.CabinetAcceleration.y, Is.LessThan(0f)); + } + + [Test] + public void StrongerActiveSensorTakesOverFromStaleSensor() + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + nudge.ConfigureSensor(1, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, -1f, 1000); + for (var i = 0; i < 5; i++) { + nudge.StepOneMillisecond(); + } + + nudge.ApplySensorSample(1, NudgeSensorChannel.AccelerationY, -12f, 2000); + for (var i = 0; i < 5; i++) { + nudge.StepOneMillisecond(); + } + + Assert.That(nudge.ActiveSourceIndex, Is.EqualTo(1)); + } + + [Test] + public void DirectCabinetSensorStrengthScalesLinearly() + { + // The reference applies the strength scale twice (upstream bug); VPE + // deliberately applies it once, so halving strength halves the output. + float RunWithStrength(float strength) + { + var nudge = new NudgeState(KeyboardNudgeMode.CabModel, 1f, 5f); + nudge.ConfigureSensor(0, new NudgeSensorRuntimeConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = strength, + CabinetMassKg = 113f, + AccelerationYMapped = 1 + }); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, 0f, 1000); + nudge.StepOneMillisecond(); + nudge.ApplySensorSample(0, NudgeSensorChannel.AccelerationY, -12f, 2000); + var peak = 0f; + for (var i = 0; i < 20; i++) { + nudge.StepOneMillisecond(); + peak = math.min(peak, nudge.CabinetAcceleration.y); + } + return peak; + } + + var fullStrength = RunWithStrength(1f); + var halfStrength = RunWithStrength(0.5f); + + Assert.That(fullStrength, Is.LessThan(0f)); + Assert.That(halfStrength, Is.EqualTo(fullStrength * 0.5f).Within(math.abs(fullStrength) * 0.02f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs.meta new file mode 100644 index 000000000..6bd225086 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/NudgeSensorStateTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d4a3e5067591475db6d3a09d379f6f86 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs new file mode 100644 index 000000000..f1f3c5496 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs @@ -0,0 +1,446 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using UnityEngine; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity +{ + /// + /// Shared cabinet input settings for runtime configuration, player config, + /// and editor presets. + /// + /// + /// This type intentionally keeps player/cabinet hardware choices separate + /// from table-authored physics values such as gravity and table nudge time. + /// The fields are Unity/JsonUtility-serializable so the same object shape can + /// be embedded in player JSON or wrapped by + /// for editor defaults. + /// + [Serializable] + public sealed class CabinetInputSettings + { + [Tooltip("Enable native input polling (requires the VisualPinball.NativeInput native plugin for the current platform).")] + public bool enableNativeInput = true; + + [Tooltip("Input polling interval in microseconds (default 500 us = 2000 Hz).")] + [Range(100, 2000)] + public int inputPollingIntervalUs = 500; + + public CabinetNudgeSettings nudge = new(); + + /// + /// Clamps values and guarantees nested objects/lists are non-null. + /// + public void Normalize() + { + inputPollingIntervalUs = Mathf.Clamp(inputPollingIntervalUs, 100, 2000); + nudge ??= new CabinetNudgeSettings(); + nudge.Normalize(); + } + + /// + /// Applies all settings that are relevant to a loaded table root. + /// + /// + /// The simulation-thread component owns native input polling settings, + /// while the physics engine owns nudge behavior. Looking both up from the + /// root lets the player app apply one settings object after loading a + /// table without knowing which component currently stores each concern. + /// + public void ApplyTo(GameObject tableRoot) + { + if (tableRoot == null) { + return; + } + + Normalize(); + ApplyTo(tableRoot.GetComponentInChildren(true)); + ApplyTo(tableRoot.GetComponentInChildren(true), false); + } + + /// + /// Applies nudge behavior to a physics engine. + /// + public void ApplyTo(PhysicsEngine physicsEngine) + { + Normalize(); + nudge.ApplyTo(physicsEngine); + } + + /// + /// Applies native input and default sensor mappings to a simulation-thread + /// component. + /// + /// + /// When true, also pushes the nested nudge settings to the sibling physics + /// engine. Pass false when the caller already applied those settings. + /// + public void ApplyTo(SimulationThreadComponent simulationThread, bool applyNudgeToPhysics = true) + { + if (simulationThread == null) { + return; + } + + Normalize(); + simulationThread.ApplyCabinetInputSettings(this, applyNudgeToPhysics); + } + + /// + /// Captures the current component fields into the shared settings shape. + /// + public static CabinetInputSettings From(PhysicsEngine physicsEngine, SimulationThreadComponent simulationThread = null) + { + var settings = new CabinetInputSettings(); + if (physicsEngine != null) { + settings.nudge = CabinetNudgeSettings.From(physicsEngine); + } + if (simulationThread != null) { + settings.enableNativeInput = simulationThread.EnableNativeInput; + settings.inputPollingIntervalUs = simulationThread.InputPollingIntervalUs; + settings.nudge ??= new CabinetNudgeSettings(); + settings.nudge.sensors = CabinetNudgeSensorSettings.FromSimulationThreadSensors(simulationThread.NudgeSensors); + } + settings.Normalize(); + return settings; + } + } + + /// + /// Shared nudge behavior settings for keyboard nudges, visual cabinet motion, + /// player tilt-bob source selection, and analog sensors. + /// + /// + /// Field names match the original player JSON config so existing + /// player-config.json files deserialize directly after moving the class + /// from the player assembly into the runtime assembly. + /// + [Serializable] + public sealed class CabinetNudgeSettings + { + public int keyboardMode = (int)KeyboardNudgeMode.CabModel; + public float keyboardStrength = 1f; + public float keyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio; + public float visualStrength = 1f; + public CabinetPlumbSettings plumb = new(); + public List sensors = new(); + + /// + /// Clamps values and guarantees nested objects/lists are non-null. + /// + public void Normalize() + { + plumb ??= new CabinetPlumbSettings(); + sensors ??= new List(); + keyboardMode = Mathf.Clamp(keyboardMode, (int)KeyboardNudgeMode.PushRetract, (int)KeyboardNudgeMode.CabModel); + keyboardStrength = Mathf.Clamp(keyboardStrength, 0f, 2f); + keyboardCabinetDamping = Mathf.Clamp(keyboardCabinetDamping, + CabinetPhysicsState.MinKeyboardDampingRatio, + CabinetPhysicsState.MaxKeyboardDampingRatio); + visualStrength = Mathf.Clamp(visualStrength, 0f, 2f); + plumb.Normalize(); + if (sensors.Count > NudgeState.MaxSensors) { + sensors.RemoveRange(NudgeState.MaxSensors, sensors.Count - NudgeState.MaxSensors); + } + for (var i = 0; i < sensors.Count; i++) { + sensors[i] ??= new CabinetNudgeSensorSettings(); + sensors[i].Normalize(); + } + } + + /// + /// Applies this settings object to a physics engine, including live state + /// when the engine is already initialized. + /// + public void ApplyTo(PhysicsEngine physicsEngine) + { + if (physicsEngine == null) { + return; + } + + physicsEngine.ConfigureNudge(this); + TiltBobComponent.ApplySettings(physicsEngine, plumb); + } + + /// + /// Converts persisted/editor sensor settings to physics-engine configs. + /// + public List ToEngineSensorConfigs() + { + Normalize(); + var sensorConfigs = new List(sensors.Count); + foreach (var sensor in sensors) { + sensorConfigs.Add(sensor.ToEngineConfig()); + } + return sensorConfigs; + } + + /// + /// Converts sensor settings to the legacy component shape used by the + /// simulation-thread inspector and packer. + /// + public List ToSimulationThreadSensorConfigs() + { + Normalize(); + var sensorConfigs = new List(sensors.Count); + foreach (var sensor in sensors) { + sensorConfigs.Add(sensor.ToSimulationThreadConfig()); + } + return sensorConfigs; + } + + /// + /// Captures the nudge fields currently stored on a physics engine. + /// + public static CabinetNudgeSettings From(PhysicsEngine physicsEngine) + { + var settings = new CabinetNudgeSettings(); + if (physicsEngine == null) { + return settings; + } + + settings.keyboardMode = (int)physicsEngine.KeyboardNudgeMode; + settings.keyboardStrength = physicsEngine.KeyboardNudgeStrength; + settings.keyboardCabinetDamping = physicsEngine.KeyboardCabinetDamping; + settings.visualStrength = physicsEngine.VisualNudgeStrength; + settings.plumb = new CabinetPlumbSettings { + enabled = true, + mode = TiltBobComponent.FindFor(physicsEngine)?.Mode ?? TiltBobMode.Simulated + }; + settings.Normalize(); + return settings; + } + } + + /// + /// Player plumb-bob tilt settings. + /// + [Serializable] + public sealed class CabinetPlumbSettings + { + /// + /// Legacy compatibility flag from the pre-component plumb setting. New + /// behavior is controlled by the presence of + /// on the table and by . + /// + [HideInInspector] + public bool enabled = true; + public TiltBobMode mode = TiltBobMode.Simulated; + + /// + /// Legacy player-config fields retained for JSON compatibility. Current + /// tilt-bob tuning is authored on . + /// + [HideInInspector] + public float damping = 1f; + + [HideInInspector] + public float thresholdDeg = 2f; + + /// + /// Clamps the tilt-bob source mode and legacy tuning fields to useful ranges. + /// + public void Normalize() + { + enabled = true; + mode = (TiltBobMode)Mathf.Clamp((int)mode, (int)TiltBobMode.Simulated, (int)TiltBobMode.Physical); + damping = Mathf.Clamp(damping, 0f, 2f); + thresholdDeg = Mathf.Clamp(thresholdDeg, 0.5f, 4f); + } + } + + /// + /// Serializable settings for one logical nudge sensor. + /// + /// + /// Mapping fields are stored as compact strings because native device ids can + /// contain punctuation and because this is the format already used by table + /// packages and player JSON. Conversion methods parse those strings into + /// objects only when runtime input needs them. + /// + [Serializable] + public sealed class CabinetNudgeSensorSettings + { + public const string TypeGamepad = "gamepad"; + public const string TypeCabinetIntent = "cabinetIntent"; + public const string TypeCabinetDirect = "cabinetDirect"; + + public string type = TypeGamepad; + public float strength = 1f; + public float cabinetMassKg = 113f; + public string x = string.Empty; + public string y = string.Empty; + public string accX = string.Empty; + public string accY = string.Empty; + public string velX = string.Empty; + public string velY = string.Empty; + public int mountRotation; + public bool mountMirror; + + /// + /// Clamps values, normalizes type names, and guarantees mapping strings are + /// non-null. + /// + public void Normalize() + { + if (type != TypeCabinetIntent && type != TypeCabinetDirect) { + type = TypeGamepad; + } + strength = Mathf.Clamp(strength, 0f, 2f); + cabinetMassKg = Mathf.Clamp(cabinetMassKg <= 0f ? 113f : cabinetMassKg, 0f, 200f); + mountRotation = Mathf.Clamp(mountRotation, (int)NudgeSensorMountRotation.Rotation0, (int)NudgeSensorMountRotation.Rotation270); + x ??= string.Empty; + y ??= string.Empty; + accX ??= string.Empty; + accY ??= string.Empty; + velX ??= string.Empty; + velY ??= string.Empty; + } + + /// + /// Converts persisted/editor settings into the runtime physics-engine + /// sensor config. + /// + public NudgeSensorConfig ToEngineConfig() + { + Normalize(); + return new NudgeSensorConfig { + Type = type switch { + TypeCabinetIntent => NudgeSensorType.CabinetIntent, + TypeCabinetDirect => NudgeSensorType.CabinetDirect, + _ => NudgeSensorType.GamepadIntent + }, + Strength = strength, + CabinetMassKg = cabinetMassKg, + MountRotation = (NudgeSensorMountRotation)mountRotation, + MountMirror = mountMirror, + X = ParseMapping(x), + Y = ParseMapping(y), + AccelerationX = ParseMapping(accX), + AccelerationY = ParseMapping(accY), + VelocityX = ParseMapping(velX), + VelocityY = ParseMapping(velY) + }; + } + + /// + /// Converts to the component shape used by the simulation-thread inspector. + /// + public SimulationThreadNudgeSensorConfig ToSimulationThreadConfig() + { + Normalize(); + return new SimulationThreadNudgeSensorConfig { + Type = type switch { + TypeCabinetIntent => NudgeSensorType.CabinetIntent, + TypeCabinetDirect => NudgeSensorType.CabinetDirect, + _ => NudgeSensorType.GamepadIntent + }, + Strength = strength, + CabinetMassKg = cabinetMassKg, + MountRotation = (NudgeSensorMountRotation)mountRotation, + MountMirror = mountMirror, + X = x, + Y = y, + AccelerationX = accX, + AccelerationY = accY, + VelocityX = velX, + VelocityY = velY + }; + } + + /// + /// Captures a runtime physics-engine sensor config back into a serializable + /// settings object. + /// + public static CabinetNudgeSensorSettings From(NudgeSensorConfig sensor) + { + sensor ??= new NudgeSensorConfig(); + sensor.Normalize(); + var settings = new CabinetNudgeSensorSettings { + type = sensor.Type switch { + NudgeSensorType.CabinetIntent => TypeCabinetIntent, + NudgeSensorType.CabinetDirect => TypeCabinetDirect, + _ => TypeGamepad + }, + strength = sensor.Strength, + cabinetMassKg = sensor.CabinetMassKg, + mountRotation = (int)sensor.MountRotation, + mountMirror = sensor.MountMirror, + x = sensor.X?.ToString() ?? string.Empty, + y = sensor.Y?.ToString() ?? string.Empty, + accX = sensor.AccelerationX?.ToString() ?? string.Empty, + accY = sensor.AccelerationY?.ToString() ?? string.Empty, + velX = sensor.VelocityX?.ToString() ?? string.Empty, + velY = sensor.VelocityY?.ToString() ?? string.Empty + }; + settings.Normalize(); + return settings; + } + + /// + /// Captures a simulation-thread component sensor config back into the + /// shared settings object. + /// + public static CabinetNudgeSensorSettings From(SimulationThreadNudgeSensorConfig sensor) + { + sensor ??= new SimulationThreadNudgeSensorConfig(); + sensor.Normalize(); + var settings = new CabinetNudgeSensorSettings { + type = sensor.Type switch { + NudgeSensorType.CabinetIntent => TypeCabinetIntent, + NudgeSensorType.CabinetDirect => TypeCabinetDirect, + _ => TypeGamepad + }, + strength = sensor.Strength, + cabinetMassKg = sensor.CabinetMassKg, + mountRotation = (int)sensor.MountRotation, + mountMirror = sensor.MountMirror, + x = sensor.X, + y = sensor.Y, + accX = sensor.AccelerationX, + accY = sensor.AccelerationY, + velX = sensor.VelocityX, + velY = sensor.VelocityY + }; + settings.Normalize(); + return settings; + } + + /// + /// Converts a simulation-thread sensor list to shared settings objects. + /// + public static List FromSimulationThreadSensors( + IReadOnlyList sensors) + { + var result = new List(); + if (sensors == null) { + return result; + } + + for (var i = 0; i < sensors.Count && i < NudgeState.MaxSensors; i++) { + result.Add(From(sensors[i])); + } + return result; + } + + private static SensorMapping ParseMapping(string value) + { + return SensorMapping.TryParse(value, out var mapping) ? mapping : new SensorMapping(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs.meta new file mode 100644 index 000000000..e017dad7d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d74627ae0fd5440eaefd6b6f5fd795b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs new file mode 100644 index 000000000..aaf9a76be --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs @@ -0,0 +1,49 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Unity asset wrapper for cabinet input defaults or named cabinet profiles. + /// + /// + /// Built players should still persist user edits to JSON. The asset exists as + /// an editor-friendly default/preset container and can be written during Play + /// Mode in the editor when a human intentionally wants to update the preset. + /// + [CreateAssetMenu(menuName = "Visual Pinball/Cabinet Input Settings", fileName = "CabinetInputSettings")] + public sealed class CabinetInputSettingsAsset : ScriptableObject + { + public CabinetInputSettings settings = new(); + + /// + /// Applies this asset's settings to a loaded table root. + /// + public void ApplyTo(GameObject tableRoot) + { + settings ??= new CabinetInputSettings(); + settings.ApplyTo(tableRoot); + } + + private void OnValidate() + { + settings ??= new CabinetInputSettings(); + settings.Normalize(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs.meta new file mode 100644 index 000000000..5ded13cce --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CabinetInputSettingsAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 566371fe86c34561850ee36ea9994990 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs new file mode 100644 index 000000000..0a4019706 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs @@ -0,0 +1,241 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity +{ + /// + /// Main-thread configuration for one logical nudge input source. + /// + /// + /// This class keeps editor/player settings in a convenient managed shape. + /// converts it to the compact struct consumed by + /// the simulation thread. + /// + public sealed class NudgeSensorConfig + { + public NudgeSensorType Type = NudgeSensorType.GamepadIntent; + public float Strength = 1f; + public float CabinetMassKg = 113f; + public NudgeSensorMountRotation MountRotation = NudgeSensorMountRotation.Rotation0; + public bool MountMirror; + public SensorMapping X = new(); + public SensorMapping Y = new(); + public SensorMapping AccelerationX = new(); + public SensorMapping AccelerationY = new(); + public SensorMapping VelocityX = new(); + public SensorMapping VelocityY = new(); + + /// + /// Clamps numeric settings and guarantees mapping objects are non-null. + /// + public void Normalize() + { + Strength = math.clamp(Strength, 0f, 2f); + CabinetMassKg = math.clamp(CabinetMassKg <= 0f ? 113f : CabinetMassKg, 0f, 200f); + MountRotation = NudgeSensorMountTransform.NormalizeRotation(MountRotation); + X ??= new SensorMapping(); + Y ??= new SensorMapping(); + AccelerationX ??= new SensorMapping(); + AccelerationY ??= new SensorMapping(); + VelocityX ??= new SensorMapping(); + VelocityY ??= new SensorMapping(); + } + + /// + /// Converts this managed config to simulation-thread state. + /// + internal NudgeSensorRuntimeConfig ToRuntimeConfig() + { + Normalize(); + return new NudgeSensorRuntimeConfig { + Type = Type, + Strength = Strength, + CabinetMassKg = CabinetMassKg, + MountRotation = MountRotation, + MountMirror = MountMirror ? (byte)1 : (byte)0, + XMapped = X.IsMapped ? (byte)1 : (byte)0, + YMapped = Y.IsMapped ? (byte)1 : (byte)0, + AccelerationXMapped = AccelerationX.IsMapped ? (byte)1 : (byte)0, + AccelerationYMapped = AccelerationY.IsMapped ? (byte)1 : (byte)0, + VelocityXMapped = VelocityX.IsMapped ? (byte)1 : (byte)0, + VelocityYMapped = VelocityY.IsMapped ? (byte)1 : (byte)0 + }; + } + } + + /// + /// Bridges native analog input events to the physics engine's nudge state. + /// + /// + /// Native input is polled off the simulation thread. This system keeps a small + /// locked snapshot of sensor mappings, maps matching axis events, and queues + /// normalized samples into the physics engine so the actual cabinet model still + /// runs at the deterministic physics cadence. + /// + public sealed class NudgeSystem : IDisposable + { + private readonly PhysicsEngine _physicsEngine; + private readonly object _configLock = new(); + private readonly List _sensors = new(NudgeState.MaxSensors); + private volatile NativeInputManager _inputManager; + + /// + /// Creates a nudge system attached to a physics engine. + /// + internal NudgeSystem(PhysicsEngine physicsEngine) + { + _physicsEngine = physicsEngine; + } + + /// + /// Number of configured sensor slots. + /// + public int SensorCount + { + get { + lock (_configLock) { + return _sensors.Count; + } + } + } + + /// + /// Returns the current native input device snapshot. + /// + public IReadOnlyList ListDevices() + { + var inputManager = _inputManager; + return inputManager == null ? Array.Empty() : inputManager.ListDevices(); + } + + /// + /// Replaces all configured nudge sensors and pushes their runtime shape to + /// the physics engine. + /// + public void ConfigureSensors(IReadOnlyList sensors) + { + lock (_configLock) { + _sensors.Clear(); + if (sensors != null) { + for (var i = 0; i < sensors.Count && i < NudgeState.MaxSensors; i++) { + var sensor = sensors[i] ?? new NudgeSensorConfig(); + sensor.Normalize(); + _sensors.Add(sensor); + } + } + + _physicsEngine.ConfigureNudgeSensorCount(_sensors.Count); + for (var i = 0; i < _sensors.Count; i++) { + _physicsEngine.ConfigureNudgeSensor(i, _sensors[i].ToRuntimeConfig()); + } + } + } + + /// + /// Subscribes to native axis events from an input manager. + /// + internal void AttachNativeInputManager(NativeInputManager inputManager) + { + if (_inputManager == inputManager) { + return; + } + DetachNativeInputManager(); + _inputManager = inputManager; + if (_inputManager != null) { + _inputManager.AxisInputReceived += OnAxisInputReceived; + } + } + + /// + /// Detaches the currently subscribed input manager if it matches. + /// + internal void DetachNativeInputManager(NativeInputManager inputManager) + { + var attachedInputManager = _inputManager; + if (attachedInputManager == null || attachedInputManager != inputManager) { + return; + } + + DetachNativeInputManager(); + } + + /// + /// Unsubscribes from native axis events. + /// + private void DetachNativeInputManager() + { + if (_inputManager != null) { + _inputManager.AxisInputReceived -= OnAxisInputReceived; + _inputManager = null; + } + } + + /// + /// Releases native input event subscriptions. + /// + public void Dispose() + { + DetachNativeInputManager(); + } + + // Called on the native input polling thread: only lock-free reads of the + // device-id snapshot here, never device (re-)enumeration. + private void OnAxisInputReceived(NativeInputApi.InputEvent evt) + { + if (evt.EventType != (int)NativeInputApi.InputEventType.Axis) { + return; + } + var inputManager = _inputManager; + if (inputManager == null || !inputManager.TryGetDeviceId(evt.DeviceIndex, out var deviceId)) { + return; + } + + lock (_configLock) { + for (var i = 0; i < _sensors.Count; i++) { + var sensor = _sensors[i]; + if (sensor.Type == NudgeSensorType.GamepadIntent) { + TryQueueSample(i, sensor.X, NudgeSensorChannel.X, deviceId, evt); + TryQueueSample(i, sensor.Y, NudgeSensorChannel.Y, deviceId, evt); + } else { + TryQueueSample(i, sensor.VelocityX, NudgeSensorChannel.VelocityX, deviceId, evt); + TryQueueSample(i, sensor.VelocityY, NudgeSensorChannel.VelocityY, deviceId, evt); + TryQueueSample(i, sensor.AccelerationX, NudgeSensorChannel.AccelerationX, deviceId, evt); + TryQueueSample(i, sensor.AccelerationY, NudgeSensorChannel.AccelerationY, deviceId, evt); + } + } + } + } + + /// + /// Matches one native axis event against one mapping and enqueues the + /// normalized sample when it belongs to that channel. + /// + private void TryQueueSample(int sensorIndex, SensorMapping mapping, NudgeSensorChannel channel, string deviceId, + NativeInputApi.InputEvent evt) + { + if (mapping == null || !mapping.IsMapped || mapping.AxisId != evt.AxisId || mapping.DeviceId != deviceId) { + return; + } + var mappedValue = mapping.ProcessRawValue(evt.Value, evt.TimestampUsec); + _physicsEngine.EnqueueNudgeSensorSample(sensorIndex, channel, mappedValue, (ulong)evt.TimestampUsec); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs.meta new file mode 100644 index 000000000..f66158554 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeSystem.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 653357e2bd134c55972fa92984606fa4 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs new file mode 100644 index 000000000..cfbfc0de8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs @@ -0,0 +1,55 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Immutable snapshot of nudge and plumb-bob diagnostics from the physics + /// thread. + /// + /// + /// The player/editor UI reads this instead of peeking into simulation state, + /// which keeps telemetry consumption decoupled from the data layout used by + /// Burst-friendly physics structs. + /// + public readonly struct NudgeTelemetry + { + public readonly float2 CabinetAcceleration; + public readonly float2 CabinetOffset; + public readonly int ActiveSourceIndex; + public readonly float3 PlumbPosition; + public readonly float PlumbTiltPercent; + public readonly int PlumbTiltIndex; + public readonly bool PlumbTiltHigh; + + /// + /// Creates a complete telemetry snapshot for one read. + /// + public NudgeTelemetry(float2 cabinetAcceleration, float2 cabinetOffset, int activeSourceIndex, + float3 plumbPosition, float plumbTiltPercent, int plumbTiltIndex, bool plumbTiltHigh) + { + CabinetAcceleration = cabinetAcceleration; + CabinetOffset = cabinetOffset; + ActiveSourceIndex = activeSourceIndex; + PlumbPosition = plumbPosition; + PlumbTiltPercent = plumbTiltPercent; + PlumbTiltIndex = plumbTiltIndex; + PlumbTiltHigh = plumbTiltHigh; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs.meta new file mode 100644 index 000000000..0dcf27ccb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/NudgeTelemetry.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8f9e01248a024825b7f07b7a6701e5b0 +timeCreated: 1783190006 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 8c53867d2..a078384bd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -59,13 +59,13 @@ namespace VisualPinball.Unity /// Threading Contract /// Four threads participate at runtime: /// - /// Unity main thread (~60-144 Hz) — rendering, UI, event + /// Unity main thread (~60-144 Hz) - rendering, UI, event /// drain, visual state application. - /// Simulation thread (1000 Hz) — physics ticks, input + /// Simulation thread (1000 Hz) - physics ticks, input /// dispatch, coil output processing, GLE time fence. - /// Native input polling thread (500-2000 Hz) — raw + /// Native input polling thread (500-2000 Hz) - raw /// keyboard/gamepad polling via the VisualPinball.NativeInput native plugin. - /// PinMAME emulation thread (variable, time-fenced) — + /// PinMAME emulation thread (variable, time-fenced) - /// ROM emulation. /// /// @@ -116,6 +116,35 @@ public class PhysicsEngine : MonoBehaviour, IPackable [Tooltip("Gravity constant, in VPX units.")] public float GravityStrength = PhysicsConstants.GravityConst * PhysicsConstants.DefaultTableGravity; + [Tooltip("Keyboard nudge model.")] + public KeyboardNudgeMode KeyboardNudgeMode = KeyboardNudgeMode.CabModel; + + [Tooltip("Keyboard nudge strength scale.")] + [Range(0f, 2f)] + public float KeyboardNudgeStrength = 1f; + + [Tooltip("Keyboard cabinet nudge damping ratio. Higher values make keyboard nudges settle faster.")] + [Range(CabinetPhysicsState.MinKeyboardDampingRatio, CabinetPhysicsState.MaxKeyboardDampingRatio)] + public float KeyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio; + + [HideInInspector] + [Tooltip("Simulate a mechanical plumb-bob tilt switch from cabinet nudge.")] + public bool SimulatedPlumb = false; + + [HideInInspector] + [Tooltip("Mechanical plumb-bob damping scale.")] + [Range(0f, 2f)] + public float PlumbDamping = 1f; + + [HideInInspector] + [Tooltip("Mechanical plumb-bob tilt threshold angle in degrees.")] + [Range(0.5f, 4f)] + public float PlumbThresholdAngle = 2f; + + [Tooltip("Render-only visual nudge strength scale.")] + [Range(0f, 2f)] + public float VisualNudgeStrength = 1f; + #endregion #region Packaging @@ -152,16 +181,23 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac // Lifecycle-local references (used during Awake/Start, then passed // to _threading constructor). [NonSerialized] private Player _player; + [NonSerialized] private NudgeSystem _nudgeSystem; + [NonSerialized] private VisualNudgeComponent _visualNudge; [NonSerialized] private ICollidableComponent[] _colliderComponents; [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents; [NonSerialized] private float4x4 _worldToPlayfield; [NonSerialized] private int _mainThreadManagedThreadId; [NonSerialized] private int _simulationThreadManagedThreadId = -1; + [NonSerialized] private int _keyboardNudgeIndex; [NonSerialized] private readonly HashSet _unsafeLiveStateAccessWarnings = new HashSet(); [NonSerialized] private bool _inputActionsQueueWarningIssued; [NonSerialized] private bool _scheduledActionsQueueWarningIssued; + [NonSerialized] private bool _pendingNudgeQueueWarningIssued; + [NonSerialized] private bool _pendingSensorSampleQueueWarningIssued; private const int InputActionsQueueWarningThreshold = 256; + private const int PendingNudgeQueueCap = 64; + private const int PendingSensorSampleQueueCap = 8192; // ~5s of a 4-channel sensor at full USB rate private const int ScheduledActionsWarningThreshold = 256; #endregion @@ -176,6 +212,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac /// Used by the simulation thread to wait for physics to be ready. /// public bool IsInitialized => _ctx != null && _ctx.IsInitialized; + public NudgeSystem NudgeSystem => _nudgeSystem; #region Ref-Return Properties @@ -266,6 +303,317 @@ internal void MutateState(InputAction action) action(ref state); } + /// + /// Monotonic counter incremented whenever a keyboard nudge is queued. + /// + /// + /// Gamelogic callbacks can consume a key press and call + /// themselves. The player checks this counter to avoid applying the built-in + /// default nudge twice for the same key press. + /// + public int KeyboardNudgeIndex => Volatile.Read(ref _keyboardNudgeIndex); + + /// + /// Captures the current nudge settings stored on this component. + /// + public CabinetNudgeSettings GetNudgeSettings() => CabinetNudgeSettings.From(this); + + /// + /// Applies the shared nudge settings object to this physics engine. + /// + public void ConfigureNudge(CabinetNudgeSettings settings) + { + settings ??= new CabinetNudgeSettings(); + settings.Normalize(); + ConfigureKeyboardNudge((KeyboardNudgeMode)settings.keyboardMode, + settings.keyboardStrength, settings.keyboardCabinetDamping); + ConfigureVisualNudge(settings.visualStrength); + ConfigureNudgeSensors(settings.ToEngineSensorConfigs()); + } + + /// + /// Applies the cabinet input settings that are relevant to physics. + /// + /// + /// Native input polling belongs to + /// , + /// so this method intentionally applies only the nested nudge settings. + /// + public void ConfigureCabinetInput(CabinetInputSettings settings) + { + settings ??= new CabinetInputSettings(); + settings.Normalize(); + settings.nudge.ApplyTo(this); + } + + /// + /// Queues a keyboard/manual nudge impulse for the next physics tick. + /// + /// + /// The request is queued instead of applied immediately so main-thread, + /// gamelogic, and native input callers all mutate nudge state on the physics + /// owner thread. + /// + public void Nudge(float angleDeg, float force) + { + Interlocked.Increment(ref _keyboardNudgeIndex); + lock (_ctx.PendingKeyboardNudgesLock) { + if (_ctx.PendingKeyboardNudges.Count >= PendingNudgeQueueCap) { + if (!_pendingNudgeQueueWarningIssued) { + _pendingNudgeQueueWarningIssued = true; + LogQueueWarning($"[PhysicsEngine] PendingKeyboardNudges backlog reached {_ctx.PendingKeyboardNudges.Count} items, dropping. Is the drain stalled?"); + } + return; + } + _ctx.PendingKeyboardNudges.Enqueue(new KeyboardNudgeCommand(angleDeg, force)); + } + } + + /// + /// Configures keyboard nudge mode and strength, keeping the current cabinet + /// damping setting. + /// + public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength) + { + ConfigureKeyboardNudge(mode, strength, KeyboardCabinetDamping); + } + + /// + /// Configures keyboard nudge response and updates live physics state when + /// the engine is already running. + /// + public void ConfigureKeyboardNudge(KeyboardNudgeMode mode, float strength, float cabinetDamping) + { + KeyboardNudgeMode = mode; + KeyboardNudgeStrength = math.clamp(strength, 0f, 2f); + KeyboardCabinetDamping = math.clamp(cabinetDamping, + CabinetPhysicsState.MinKeyboardDampingRatio, + CabinetPhysicsState.MaxKeyboardDampingRatio); + + if (_ctx == null || !_ctx.IsInitialized) { + return; + } + + var table = GetComponentInParent(); + var nudgeTime = table != null ? table.NudgeTime : 5f; + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + nudge.Keyboard.Configure(KeyboardNudgeMode, KeyboardNudgeStrength, nudgeTime, KeyboardCabinetDamping); + _ctx.PhysicsEnv.Nudge = nudge; + } + } + + /// + /// Configures the simulated mechanical plumb-bob tilt switch. + /// + public void ConfigurePlumb(bool simulatedPlumb, float damping, float thresholdAngle) + { + SimulatedPlumb = simulatedPlumb; + PlumbDamping = math.clamp(damping, 0f, 2f); + PlumbThresholdAngle = math.clamp(thresholdAngle, 0.5f, 4f); + + if (_ctx == null || !_ctx.IsInitialized) { + return; + } + + lock (_ctx.PhysicsLock) { + var plumb = _ctx.PhysicsEnv.Plumb; + plumb.Configure(SimulatedPlumb, PlumbDamping, PlumbThresholdAngle); + _ctx.PhysicsEnv.Plumb = plumb; + } + } + + /// + /// Configures render-only visual nudge strength and ensures the runtime + /// camera offset component exists in Play Mode. + /// + public void ConfigureVisualNudge(float strength) + { + VisualNudgeStrength = math.clamp(strength, 0f, 2f); + if (!Application.isPlaying) { + return; + } + EnsureVisualNudge(); + _visualNudge?.Configure(this, VisualNudgeStrength); + } + + /// + /// Finds or adds the component that applies visual nudge to game cameras. + /// + private void EnsureVisualNudge() + { + if (_visualNudge != null) { + return; + } + _visualNudge = GetComponent(); + if (_visualNudge == null) { + _visualNudge = gameObject.AddComponent(); + } + } + + /// + /// Replaces the analog nudge sensor configuration. + /// + public void ConfigureNudgeSensors(IReadOnlyList sensors) + { + _nudgeSystem?.ConfigureSensors(sensors); + } + + /// + /// Updates the number of active analog sensor slots inside physics state. + /// + internal void ConfigureNudgeSensorCount(int count) + { + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + nudge.ConfigureSensors(count); + _ctx.PhysicsEnv.Nudge = nudge; + } + } + + /// + /// Updates one analog nudge sensor slot inside physics state. + /// + internal void ConfigureNudgeSensor(int index, NudgeSensorRuntimeConfig config) + { + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + nudge.ConfigureSensor(index, config); + _ctx.PhysicsEnv.Nudge = nudge; + } + } + + /// + /// Queues one mapped analog sensor sample for physics-thread processing. + /// + /// + /// A streaming accelerometer can generate thousands of samples per second, + /// so the queue is bounded and drops the oldest sample if the drain stalls. + /// Fresh samples are more useful than stale motion history after a pause or + /// debugger break. + /// + internal void EnqueueNudgeSensorSample(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) + { + lock (_ctx.PendingNudgeSensorSamplesLock) { + if (_ctx.PendingNudgeSensorSamples.Count >= PendingSensorSampleQueueCap) { + // A stalled drain (pause, breakpoint) must not let a streaming + // accelerometer grow the queue without bound; new samples win. + _ctx.PendingNudgeSensorSamples.Dequeue(); + if (!_pendingSensorSampleQueueWarningIssued) { + _pendingSensorSampleQueueWarningIssued = true; + LogQueueWarning($"[PhysicsEngine] PendingNudgeSensorSamples backlog reached {PendingSensorSampleQueueCap} items, dropping oldest. Is the drain stalled?"); + } + } + _ctx.PendingNudgeSensorSamples.Enqueue(new NudgeSensorSampleCommand(sensorIndex, channel, value, timestampUsec)); + } + } + + /// + /// Subscribes the nudge system to native input axis events. + /// + internal void AttachNativeInputManager(NativeInputManager inputManager) + { + _nudgeSystem?.AttachNativeInputManager(inputManager); + } + + /// + /// Unsubscribes the nudge system from a native input manager. + /// + internal void DetachNativeInputManager(NativeInputManager inputManager) + { + _nudgeSystem?.DetachNativeInputManager(inputManager); + } + + /// + /// VP-script-compatible nudge status readout. + /// + /// + /// Returns the largest signed cabinet acceleration since the last read and + /// then clears the accumulator, matching VP's polling-oriented script API. + /// + public void NudgeSensorStatus(out float x, out float y) + { + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + var acceleration = nudge.ReadAndResetMaxCabinetAcceleration(); + _ctx.PhysicsEnv.Nudge = nudge; + x = acceleration.x; + y = acceleration.y; + } + } + + /// + /// VP-script-compatible plumb tilt readout. + /// + public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPercent) + { + lock (_ctx.PhysicsLock) { + var plumb = _ctx.PhysicsEnv.Plumb; + var status = plumb.ReadAndResetTiltStatus(); + _ctx.PhysicsEnv.Plumb = plumb; + plumbX = status.x; + plumbY = status.y; + tiltPercent = status.z; + } + } + + /// + /// Returns a read-only nudge/plumb telemetry snapshot for UI and editor + /// diagnostics. + /// + public NudgeTelemetry GetNudgeTelemetry() + { + if (_ctx == null || !_ctx.IsInitialized) { + return default; + } + + lock (_ctx.PhysicsLock) { + var nudge = _ctx.PhysicsEnv.Nudge; + var plumb = _ctx.PhysicsEnv.Plumb; + var threshold = plumb.TiltThresholdRad; + var tiltPercent = 0f; + if (threshold > 0f && plumb.PoleLength > 0f) { + var psi = math.atan2(math.sqrt(plumb.Position.x * plumb.Position.x + plumb.Position.y * plumb.Position.y), -plumb.Position.z); + tiltPercent = 100f * psi / threshold; + } + return new NudgeTelemetry( + nudge.CabinetAcceleration, + nudge.CabinetOffset, + nudge.ActiveSourceIndex, + plumb.Position, + tiltPercent, + plumb.TiltIndex, + plumb.TiltHigh + ); + } + } + + /// + /// Applies the latest physics-produced cabinet offset to visual nudge. + /// + internal void ApplyVisualNudge(float2 cabinetOffset) + { + if (_visualNudge == null) { + return; + } + _visualNudge.SetCabinetOffset(cabinetOffset); + } + + /// + /// Moves queued plumb-bob tilt switch edges into the caller's list. + /// + internal void DrainPlumbTiltEvents(List destination) + { + lock (_ctx.PhysicsLock) { + var plumb = _ctx.PhysicsEnv.Plumb; + for (var i = 0; i < plumb.PendingTiltStates.Length; i++) { + destination.Add(plumb.PendingTiltStates[i] != 0); + } + plumb.ClearPendingTiltEvents(); + _ctx.PhysicsEnv.Plumb = plumb; + } + } + internal void MarkCurrentThreadAsSimulationThread() { Interlocked.Exchange(ref _simulationThreadManagedThreadId, Thread.CurrentThread.ManagedThreadId); @@ -307,7 +655,7 @@ private static void LogQueueWarning(string message) Debug.LogWarning(message); } - // ── State accessors ────────────────────────────────────────── + // State accessors // These return refs into native hash maps. In single-threaded // mode they are safe. In threaded mode, callers on the sim // thread (e.g. FlipperApi coil callbacks) access them inside @@ -452,8 +800,9 @@ internal void RegisterRuntimeBall(BallComponent ball) if (_ctx.UseExternalTiming) { lock (_ctx.PhysicsLock) { - if (_ctx.BallStates.Ref.IsCreated && !_ctx.BallStates.Ref.ContainsKey(ballId)) { - _ctx.BallStates.Ref[ballId] = ballState; + ref var ballStates = ref _ctx.BallStates.Ref; + if (!ballStates.ContainsKey(ballId)) { + ballStates[ballId] = ballState; } } return; @@ -512,7 +861,7 @@ internal void DisableCollider(int itemId) /// public bool TryGetKinematicVelocity(int itemId, out float3 linearVelocity, out float3 angularVelocity, out float3 pivot) { - // engine time unit (DefaultStepTime, 10 ms) → seconds + // engine time unit (DefaultStepTime, 10 ms) to seconds const float perSecond = (float)(1e6 / PhysicsConstants.DefaultStepTime); lock (_ctx.PhysicsLock) { if (_ctx.KinematicVelocities.Ref.IsCreated && _ctx.KinematicVelocities.Ref.TryGetValue(itemId, out var velocity)) { @@ -530,7 +879,7 @@ public bool TryGetKinematicVelocity(int itemId, out float3 linearVelocity, out f #endregion - #region Forwarding — Simulation Thread + #region Forwarding - Simulation Thread /// /// Execute a single physics tick with external timing. @@ -589,8 +938,16 @@ private void Awake() { _mainThreadManagedThreadId = Thread.CurrentThread.ManagedThreadId; _player = GetComponentInParent(); + _nudgeSystem = new NudgeSystem(this); + ConfigureVisualNudge(VisualNudgeStrength); + if (TiltBobComponent.FindFor(this) == null) { + SimulatedPlumb = false; + } _ctx.InsideOfs = new InsideOfs(Allocator.Persistent); - _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength); + var table = GetComponentInParent(); + _ctx.PhysicsEnv = new PhysicsEnv(NowUsec, GetComponentInChildren(), GravityStrength, + KeyboardNudgeMode, KeyboardNudgeStrength, table != null ? table.NudgeTime : 5f, + SimulatedPlumb, PlumbDamping, PlumbThresholdAngle, KeyboardCabinetDamping); Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime); _ctx.ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); @@ -694,6 +1051,13 @@ private void Start() // Mark as initialized for simulation thread _ctx.IsInitialized = true; + + // Awake built PhysicsEnv from the serialized defaults; settings applied + // between Awake and Start (the player app pushes persisted config right + // after table activation) only reached the component fields because the + // Configure methods bail out while uninitialized. Push them now. + ConfigureKeyboardNudge(KeyboardNudgeMode, KeyboardNudgeStrength, KeyboardCabinetDamping); + ConfigurePlumb(SimulatedPlumb, PlumbDamping, PlumbThresholdAngle); } internal PhysicsState CreateState() => _ctx.CreateState(); @@ -763,6 +1127,8 @@ private void OnDestroy() _ctx.IsInitialized = false; _ctx.UseExternalTiming = false; + _nudgeSystem?.Dispose(); + _nudgeSystem = null; lock (_ctx.PhysicsLock) { _ctx.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index a621d6618..b96032d1e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -193,6 +193,20 @@ internal class PhysicsEngineContext : IDisposable public readonly Queue InputActions = new(); public readonly object InputActionsLock = new(); + /// + /// Keyboard/script nudge impulses waiting to be applied to the persistent + /// cabinet state. Drained inside PhysicsLock before the physics loop. + /// + public readonly Queue PendingKeyboardNudges = new(); + public readonly object PendingKeyboardNudgesLock = new(); + + /// + /// Analog nudge sensor samples waiting to be applied to the persistent + /// cabinet state. Drained inside PhysicsLock before the physics loop. + /// + public readonly Queue PendingNudgeSensorSamples = new(); + public readonly object PendingNudgeSensorSamplesLock = new(); + /// /// Scheduled managed callbacks stored in a min-heap by due time. /// Protected by locking on . diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs index b418aefee..a3b14aeea 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnginePackable.cs @@ -14,23 +14,69 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -namespace VisualPinball.Unity -{ - public struct PhysicsEnginePackable - { - public float GravityStrength; - - public static byte[] Pack(PhysicsEngine comp) - { - return PackageApi.Packer.Pack(new PhysicsEnginePackable { - GravityStrength = comp.GravityStrength, - }); - } - - public static void Unpack(byte[] bytes, PhysicsEngine comp) - { - var data = PackageApi.Packer.Unpack(bytes); - comp.GravityStrength = data.GravityStrength; - } - } -} +namespace VisualPinball.Unity +{ + /// + /// Packaged representation of settings. + /// + /// + /// The boolean "Has..." flags preserve backward compatibility with packages + /// created before keyboard nudge, plumb, damping, or visual nudge settings were + /// serialized. Plumb fields are retained only so older packages can be read; + /// new packages route tilt through plus player + /// cabinet settings instead of table-owned physics settings. + /// + public struct PhysicsEnginePackable + { + public float GravityStrength; + public bool HasKeyboardNudgeSettings; + public KeyboardNudgeMode KeyboardNudgeMode; + public float KeyboardNudgeStrength; + public bool HasKeyboardCabinetDamping; + public float KeyboardCabinetDamping; + public bool HasPlumbSettings; + public bool SimulatedPlumb; + public float PlumbDamping; + public float PlumbThresholdAngle; + public bool HasVisualNudgeSettings; + public float VisualNudgeStrength; + + /// + /// Serializes physics engine configuration for a table package. + /// + public static byte[] Pack(PhysicsEngine comp) + { + return PackageApi.Packer.Pack(new PhysicsEnginePackable { + GravityStrength = comp.GravityStrength, + HasKeyboardNudgeSettings = true, + KeyboardNudgeMode = comp.KeyboardNudgeMode, + KeyboardNudgeStrength = comp.KeyboardNudgeStrength, + HasKeyboardCabinetDamping = true, + KeyboardCabinetDamping = comp.KeyboardCabinetDamping, + HasPlumbSettings = false, + SimulatedPlumb = false, + PlumbDamping = 1f, + PlumbThresholdAngle = 2f, + HasVisualNudgeSettings = true, + VisualNudgeStrength = comp.VisualNudgeStrength, + }); + } + + /// + /// Restores physics engine configuration from package data. + /// + public static void Unpack(byte[] bytes, PhysicsEngine comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.GravityStrength = data.GravityStrength; + comp.ConfigureNudge(new CabinetNudgeSettings { + keyboardMode = (int)(data.HasKeyboardNudgeSettings ? data.KeyboardNudgeMode : KeyboardNudgeMode.CabModel), + keyboardStrength = data.HasKeyboardNudgeSettings ? data.KeyboardNudgeStrength : 1f, + keyboardCabinetDamping = data.HasKeyboardCabinetDamping + ? data.KeyboardCabinetDamping + : CabinetPhysicsState.DefaultKeyboardDampingRatio, + visualStrength = data.HasVisualNudgeSettings ? data.VisualNudgeStrength : 1f + }); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index ae9e4e994..2d148ecb1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -57,6 +57,8 @@ internal class PhysicsEngineThreading private readonly List _deferredMainThreadScheduledActions = new(); private readonly List _dueSingleThreadScheduledActions = new(); private readonly List _pendingInputActions = new(); + private readonly List _pendingKeyboardNudges = new(); + private readonly List _pendingNudgeSensorSamples = new(); private readonly List> _pendingKinematicUpdates = new(); private readonly List _pendingKinematicStopUpdates = new(); @@ -195,6 +197,8 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) // process input ProcessInputActions(ref state); + ProcessPendingKeyboardNudges(); + ProcessPendingNudgeSensorSamples(); // run physics loop (Burst-compiled, thread-safe) PhysicsUpdate.Execute( @@ -233,6 +237,56 @@ private void ProcessInputActions(ref PhysicsState state) } } + /// + /// Drains queued keyboard/manual nudge commands into physics-owned state. + /// + /// + /// Nudge requests can be produced by main-thread input, gamelogic, or native + /// input callbacks, but the cabinet model is mutated only while the physics + /// thread owns . + /// + private void ProcessPendingKeyboardNudges() + { + _pendingKeyboardNudges.Clear(); + + lock (_ctx.PendingKeyboardNudgesLock) { + while (_ctx.PendingKeyboardNudges.Count > 0) { + _pendingKeyboardNudges.Add(_ctx.PendingKeyboardNudges.Dequeue()); + } + } + + foreach (var nudge in _pendingKeyboardNudges) { + var nudgeState = _ctx.PhysicsEnv.Nudge; + nudgeState.ApplyKeyboardImpulse(nudge.AngleDeg, nudge.Force); + _ctx.PhysicsEnv.Nudge = nudgeState; + } + } + + /// + /// Drains queued analog nudge sensor samples into physics-owned state. + /// + /// + /// The input thread may deliver several samples between physics ticks. They + /// are replayed in queue order so the sensor filters retain native timing + /// information while still mutating state on the physics thread. + /// + private void ProcessPendingNudgeSensorSamples() + { + _pendingNudgeSensorSamples.Clear(); + + lock (_ctx.PendingNudgeSensorSamplesLock) { + while (_ctx.PendingNudgeSensorSamples.Count > 0) { + _pendingNudgeSensorSamples.Add(_ctx.PendingNudgeSensorSamples.Dequeue()); + } + } + + foreach (var sample in _pendingNudgeSensorSamples) { + var nudgeState = _ctx.PhysicsEnv.Nudge; + nudgeState.ApplySensorSample(sample.SensorIndex, sample.Channel, sample.Value, sample.TimestampUsec); + _ctx.PhysicsEnv.Nudge = nudgeState; + } + } + /// /// Apply kinematic transforms staged by the main thread into the /// physics state maps, deriving each item's velocity from the @@ -553,6 +607,13 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) _float2SnapshotOverflowWarningIssued = true; Logger.Warn($"[PhysicsEngine] Float2 animation snapshot capacity exceeded: {snapshot.Float2AnimationSourceCount} channels for max {SimulationState.MaxFloat2Animations}. Snapshot output is truncated."); } + + snapshot.NudgeCabinetAcceleration = _ctx.PhysicsEnv.Nudge.CabinetAcceleration; + snapshot.NudgeCabinetOffset = _ctx.PhysicsEnv.Nudge.CabinetOffset; + snapshot.PlumbPosition = _ctx.PhysicsEnv.Plumb.Position; + snapshot.PlumbTiltPercent = _ctx.PhysicsEnv.Plumb.MaxTiltPercent; + snapshot.PlumbTiltIndex = _ctx.PhysicsEnv.Plumb.TiltIndex; + snapshot.PlumbTiltHigh = _ctx.PhysicsEnv.Plumb.TiltHigh ? (byte)1 : (byte)0; } #endregion @@ -628,6 +689,8 @@ private void ApplyMovementsFromSnapshot(in SimulationState.Snapshot snapshot) emitter.UpdateAnimationValue(anim.Value); } } + + _physicsEngine.ApplyVisualNudge(snapshot.NudgeCabinetOffset); } /// @@ -783,6 +846,8 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) // process input ProcessInputActions(ref state); + ProcessPendingKeyboardNudges(); + ProcessPendingNudgeSensorSamples(); // run physics loop PhysicsUpdate.Execute( @@ -834,6 +899,7 @@ private void ApplyAllMovements(ref PhysicsState state) _physicsMovements.ApplyPlungerMovement(ref _ctx.PlungerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplySpinnerMovement(ref _ctx.SpinnerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplyTriggerMovement(ref _ctx.TriggerStates.Ref, _ctx.FloatAnimatedComponents); + _physicsEngine.ApplyVisualNudge(_ctx.PhysicsEnv.Nudge.CabinetOffset); } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs index edca08c04..63b54e721 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEnv.cs @@ -19,25 +19,46 @@ using VisualPinball.Engine.Common; using Random = Unity.Mathematics.Random; -namespace VisualPinball.Unity -{ - public struct PhysicsEnv - { +namespace VisualPinball.Unity +{ + /// + /// Per-table physics environment values that are independent of individual + /// component state. + /// + /// + /// This struct travels with the physics state through both main-thread and + /// simulation-thread execution. Nudge and plumb state live here because they + /// affect every ball and must advance once per one-millisecond physics tick, + /// not once per Unity frame. + /// + public struct PhysicsEnv + { public readonly float3 Gravity; public readonly ulong StartTimeUsec; public ulong CurPhysicsFrameTime; public ulong NextPhysicsFrameTime; - public uint TimeMsec; - - public Random Random; - - public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravityStrength) : this() - { - StartTimeUsec = startTimeUsec; - CurPhysicsFrameTime = StartTimeUsec; - NextPhysicsFrameTime = StartTimeUsec + PhysicsConstants.PhysicsStepTime; - Random = new Random((uint)UnityEngine.Random.Range(1, 100000)); - Gravity = playfield.PlayfieldGravity(gravityStrength); - } - } -} + public uint TimeMsec; + + public Random Random; + public NudgeState Nudge; + public PlumbState Plumb; + + /// + /// Creates the physics environment for a table, including gravity, cabinet + /// nudge state, and plumb-bob state. + /// + public PhysicsEnv(ulong startTimeUsec, PlayfieldComponent playfield, float gravityStrength, + KeyboardNudgeMode keyboardNudgeMode = KeyboardNudgeMode.CabModel, float keyboardNudgeStrength = 1f, + float nudgeTime = 5f, bool simulatedPlumb = true, float plumbDamping = 1f, float plumbThresholdAngle = 2f, + float keyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) : this() + { + StartTimeUsec = startTimeUsec; + CurPhysicsFrameTime = StartTimeUsec; + NextPhysicsFrameTime = StartTimeUsec + PhysicsConstants.PhysicsStepTime; + Random = new Random((uint)UnityEngine.Random.Range(1, 100000)); + Gravity = playfield.PlayfieldGravity(gravityStrength); + Nudge = new NudgeState(keyboardNudgeMode, keyboardNudgeStrength, nudgeTime, keyboardCabinetDamping); + Plumb = new PlumbState(simulatedPlumb, plumbDamping, plumbThresholdAngle); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 05464f515..ad5e5f469 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -22,10 +22,19 @@ // ReSharper disable InconsistentNaming -namespace VisualPinball.Unity -{ - [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] - internal static class PhysicsUpdate +namespace VisualPinball.Unity +{ + /// + /// Burst-compiled one-millisecond physics update loop. + /// + /// + /// Cabinet nudge is advanced here before velocity integration so balls and the + /// simulated plumb bob see the same cabinet acceleration for the tick. That + /// mirrors VP's cabinet physics flow, where nudge is an inertial acceleration + /// applied to the entire playfield rather than a per-ball impulse. + /// + [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] + internal static class PhysicsUpdate { // [ReadOnly] // public ulong InitialTimeUsec; @@ -66,8 +75,11 @@ internal static class PhysicsUpdate // // public bool SwapBallCollisionHandling; - [BurstCompile] - public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kinematicOctree, ref NativeOctree ballOctree, ref PhysicsCycle cycle, ulong initialTimeUsec) + /// + /// Advances physics until simulated time catches up to the requested time. + /// + [BurstCompile] + public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kinematicOctree, ref NativeOctree ballOctree, ref PhysicsCycle cycle, ulong initialTimeUsec) { // ref var state = ref UnsafeUtility.AsRef(statePtr.ToPointer()); // ref var env = ref UnsafeUtility.AsRef(envPtr.ToPointer()); @@ -92,15 +104,22 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ env.TimeMsec = (uint)((env.CurPhysicsFrameTime - env.StartTimeUsec) / 1000); var physicsDiffTime = (float)((env.NextPhysicsFrameTime - env.CurPhysicsFrameTime) * (1.0 / PhysicsConstants.DefaultStepTime)); - // update velocities - always on integral physics frame boundary (spinner, gate, flipper, plunger, ball) - #region Update Velocities - - // balls - using (var enumerator = state.Balls.GetEnumerator()) { - while (enumerator.MoveNext()) { - BallVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value, env.Gravity); - } - } + // update velocities - always on integral physics frame boundary (spinner, gate, flipper, plunger, ball) + #region Update Velocities + + // Nudge is evaluated once per millisecond before ball velocity updates + // so every ball receives the same cabinet inertial acceleration for + // this tick, and the plumb bob records the same motion. + env.Nudge.StepOneMillisecond(); + var cabinetAcceleration = env.Nudge.CabinetAcceleration; + env.Plumb.StepOneMillisecond(cabinetAcceleration); + + // balls + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + BallVelocityPhysics.UpdateVelocities(ref enumerator.Current.Value, env.Gravity, cabinetAcceleration); + } + } // flippers using (var enumerator = state.FlipperStates.GetEnumerator()) { while (enumerator.MoveNext()) { @@ -147,10 +166,13 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ if (subSteps > 0) { UpdateAnimations(ref state, env.TimeMsec, subSteps * (PhysicsConstants.PhysicsStepTime / 1000f)); - } - } - - private static void UpdateAnimations(ref PhysicsState state, uint animationTimeMsec, float animationDeltaTimeMs) + } + } + + /// + /// Advances mechanical animations after physics substeps have completed. + /// + private static void UpdateAnimations(ref PhysicsState state, uint animationTimeMsec, float animationDeltaTimeMs) { // bumper using (var enumerator = state.BumperStates.GetEnumerator()) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 9a79e8759..b328bb7e4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -70,13 +70,14 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public event EventHandler OnPlayerStarted; public List SwitchMapping => _tableComponent.MappingConfig.Switches; - public List CoilMapping => _tableComponent.MappingConfig.Coils; - public List LampMapping => _tableComponent.MappingConfig.Lamps; - - public event EventHandler OnUpdate; - - public event EventHandler OnBallCreated; - public event EventHandler OnBallDestroyed; + public List CoilMapping => _tableComponent.MappingConfig.Coils; + public List LampMapping => _tableComponent.MappingConfig.Lamps; + + public event EventHandler OnUpdate; + internal event Action CabinetInputActionChanged; + + public event EventHandler OnBallCreated; + public event EventHandler OnBallDestroyed; public float4x4 PlayfieldToWorldMatrix => PlayfieldComponent.transform.localToWorldMatrix; @@ -94,9 +95,20 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac internal IEnumerable ColliderGenerators => _colliderGenerators; - // input related - [NonSerialized] private InputManager _inputManager; - [NonSerialized] private readonly List<(InputAction, Action)> _actions = new(); + // input related + [NonSerialized] private InputManager _inputManager; + [NonSerialized] private readonly List<(InputAction, Action)> _actions = new(); + [NonSerialized] private readonly System.Random _nudgeRandom = new System.Random(); + + // Default-keyboard-nudge suppression (Unity input path only): the nudge counter is + // snapshotted before each input-system update, i.e. before any action callback (and + // therefore any synchronous gamelogic reaction) of that update ran. Nudge key presses + // are recorded with that snapshot and evaluated in Update(), after all callbacks ran: + // if gamelogic called Nudge() in reaction to the key, the counter moved and the + // default nudge is suppressed — mirroring the tight bracket of the reference and of + // the simulation-thread path. + [NonSerialized] private int _nudgeIndexAtInputUpdateStart; + [NonSerialized] private readonly List<(string ActionName, int IndexBefore)> _pendingDefaultNudges = new(); // players [NonSerialized] private readonly LampPlayer _lampPlayer = new(); @@ -133,14 +145,19 @@ private PhysicsEngine PhysicsEngine { } } - private SimulationThreadComponent SimulationThreadComponent { - get { - if (_simulationThreadComponent == null) { - _simulationThreadComponent = GetComponent(); - } - return _simulationThreadComponent; - } - } + private SimulationThreadComponent SimulationThreadComponent { + get { + if (_simulationThreadComponent == null) { + _simulationThreadComponent = GetComponentInChildren(true); + } + return _simulationThreadComponent; + } + } + + private void EnsureSimulationThreadComponent() + { + _simulationThreadComponent = Simulation.SimulationThreadComponent.EnsureFor(PhysicsEngine); + } #region Access @@ -150,14 +167,21 @@ private SimulationThreadComponent SimulationThreadComponent { public IApiWireDeviceDest WireDevice(IWireableComponent c) => _wirePlayer.WireDevice(c); internal void HandleWireSwitchChange(WireDestConfig wireConfig, bool isEnabled) => _wirePlayer.HandleSwitchChange(wireConfig, isEnabled); - internal void DispatchSwitch(string switchId, bool isClosed) - { - if (SimulationThreadComponent != null && SimulationThreadComponent.EnqueueSwitchFromMainThread(switchId, isClosed)) { - return; - } - GamelogicEngine?.Switch(switchId, isClosed); - } - + internal void DispatchSwitch(string switchId, bool isClosed) + { + if (SimulationThreadComponent != null && SimulationThreadComponent.EnqueueSwitchFromMainThread(switchId, isClosed)) { + return; + } + GamelogicEngine?.Switch(switchId, isClosed); + } + + internal void DispatchInputAction(string actionName, bool isPressed) + { + _switchPlayer.DispatchInputAction(actionName, isPressed); + _wirePlayer.DispatchInputAction(actionName, isPressed); + CabinetInputActionChanged?.Invoke(actionName, isPressed); + } + internal bool DispatchCoilSimulationThread(string coilId, bool isEnabled) { return _coilPlayer.HandleCoilEventSimulationThread(coilId, isEnabled); @@ -192,11 +216,11 @@ private void Awake() _apis.Add(TableApi); - BallManager = new BallManager(GetComponentInChildren(), this, Playfield.transform); - _inputManager = new InputManager(); - _inputManager.Enable(HandleInput); - - if (engineComponent != null) { + BallManager = new BallManager(GetComponentInChildren(), this, Playfield.transform); + _inputManager = new InputManager(); + EnsureSimulationThreadComponent(); + + if (engineComponent != null) { GamelogicEngine = engineComponent; _lampPlayer.Awake(this, _tableComponent, GamelogicEngine); _coilPlayer.Awake(this, _tableComponent, GamelogicEngine, _lampPlayer, _wirePlayer); @@ -218,9 +242,11 @@ private async void Start() } _coilPlayer.OnStart(); - _switchPlayer.OnStart(); - _lampPlayer.OnStart(); - _wirePlayer.OnStart(); + _switchPlayer.OnStart(); + _lampPlayer.OnStart(); + _wirePlayer.OnStart(); + _inputManager.Enable(HandleInput); + InputSystem.onBeforeUpdate += OnBeforeInputUpdate; _gamelogicEngineInitCts = new CancellationTokenSource(); try { @@ -231,6 +257,15 @@ private async void Start() private void Update() { + if (_pendingDefaultNudges.Count > 0) { + foreach (var (actionName, indexBefore) in _pendingDefaultNudges) { + if (PhysicsEngine.KeyboardNudgeIndex == indexBefore) { + ApplyDefaultKeyboardNudge(actionName); + } + } + _pendingDefaultNudges.Clear(); + } + OnUpdate?.Invoke(this, EventArgs.Empty); } @@ -243,6 +278,7 @@ private void OnDestroy() i.OnDestroy(); } + InputSystem.onBeforeUpdate -= OnBeforeInputUpdate; _inputManager.Disable(HandleInput); _coilPlayer.OnDestroy(); _switchPlayer.OnDestroy(); @@ -346,8 +382,17 @@ private void RegisterCollider(int itemId, IApiColliderGenerator apiColl) #region Events - public void ScheduleAction(int timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); - public void ScheduleAction(uint timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); + public void ScheduleAction(int timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); + public void ScheduleAction(uint timeMs, Action action) => PhysicsEngine.ScheduleAction(timeMs, action); + public void Nudge(float angleDeg, float force) => PhysicsEngine.Nudge(angleDeg, force); + public void NudgeSensorStatus(out float x, out float y) => PhysicsEngine.NudgeSensorStatus(out x, out y); + public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPercent) => PhysicsEngine.NudgeTiltStatus(out plumbX, out plumbY, out tiltPercent); + public void NudgeGetCalibration(out float x, out float y) + { + x = 0f; + y = 0f; + } + public void NudgeSetCalibration(float x, float y) { } public void OnEvent(in EventData eventData) { @@ -456,13 +501,22 @@ public void RemoveHardwareRule(string switchId, string coilId) #endregion - private static void HandleInput(object obj, InputActionChange change) - { - if (obj is InputAction action && action.actionMap != null && action.actionMap.name == InputConstants.MapDebug) { - var value = action.ReadValue(); - switch (action.name) { - case InputConstants.ActionSlowMotion: { - switch (change) { + private void HandleInput(object obj, InputActionChange change) + { + if (obj is not InputAction action || action.actionMap == null) { + return; + } + + if (action.actionMap.name == InputConstants.MapCabinetSwitches) { + HandleCabinetInputAction(action, change); + HandleNudgeInput(action, change); + } + + if (action.actionMap.name == InputConstants.MapDebug) { + var value = action.ReadValue(); + switch (action.name) { + case InputConstants.ActionSlowMotion: { + switch (change) { case InputActionChange.ActionPerformed when value > 0.1: Time.timeScale = math.lerp(1f, SlowMotionMax, value); break; @@ -490,10 +544,65 @@ private static void HandleInput(object obj, InputActionChange change) Logger.Info("Timescale = " + Time.timeScale); break; } - } - } + } + } + } + + private void HandleCabinetInputAction(InputAction action, InputActionChange change) + { + if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true) { + return; + } + + if (change != InputActionChange.ActionStarted && change != InputActionChange.ActionCanceled) { + return; + } + + CabinetInputActionChanged?.Invoke(InputManager.GetCanonicalActionName(action.name), + change == InputActionChange.ActionStarted); + } + + private void HandleNudgeInput(InputAction action, InputActionChange change) + { + if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true || change != InputActionChange.ActionStarted) { + return; + } + + var actionName = InputManager.GetCanonicalActionName(action.name); + if (actionName != InputConstants.ActionLeftNudge + && actionName != InputConstants.ActionRightNudge + && actionName != InputConstants.ActionCenterNudge) { + return; + } + + _pendingDefaultNudges.Add((actionName, _nudgeIndexAtInputUpdateStart)); } - } + + private void OnBeforeInputUpdate() + { + if (PhysicsEngine != null) { + _nudgeIndexAtInputUpdateStart = PhysicsEngine.KeyboardNudgeIndex; + } + } + + private void ApplyDefaultKeyboardNudge(string actionName) + { + const float baseForce = 2f; + var angle = ((float)_nudgeRandom.NextDouble() - 0.5f) * 15f * baseForce; + var force = (0.6f + (float)_nudgeRandom.NextDouble() * 0.8f) * baseForce; + switch (actionName) { + case InputConstants.ActionLeftNudge: + Nudge(75f + angle, force); + break; + case InputConstants.ActionRightNudge: + Nudge(285f + angle, force); + break; + case InputConstants.ActionCenterNudge: + Nudge(angle, force); + break; + } + } + } public readonly struct BallEvent { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs index c3cbfe501..72889f42c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs @@ -139,23 +139,30 @@ private void HandleKeyInput(object obj, InputActionChange change) switch (change) { case InputActionChange.ActionStarted: case InputActionChange.ActionCanceled: - 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; - _player.DispatchSwitch(sw.SwitchId, sw.IsSwitchClosed); - } - } - } else { - //Logger.Info($"Unmapped input command \"{action.name}\"."); - } - break; - } - } - - public void OnDestroy() + var action = (InputAction)obj; + var actionName = InputManager.GetCanonicalActionName(action.name); + if (!DispatchInputAction(actionName, change == InputActionChange.ActionStarted)) { + //Logger.Info($"Unmapped input command \"{action.name}\"."); + } + break; + } + } + + internal bool DispatchInputAction(string actionName, bool isPressed) + { + if (!_keySwitchAssignments.TryGetValue(actionName, out var assignment)) { + return false; + } + if (_player != null) { + foreach (var sw in assignment) { + sw.IsSwitchEnabled = isPressed; + _player.DispatchSwitch(sw.SwitchId, sw.IsSwitchClosed); + } + } + return true; + } + + public void OnDestroy() { if (_keySwitchAssignments.Count > 0) { _inputManager.Disable(HandleKeyInput); @@ -195,4 +202,4 @@ public ConstantSwitch(bool isSwitchClosed) IsSwitchEnabled = isSwitchClosed; } } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs new file mode 100644 index 000000000..2bfa5b458 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs @@ -0,0 +1,155 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Applies a camera-space visual response to simulated cabinet motion. + /// + /// + /// This mirrors VP's visual nudge behavior in + /// vpinball/src/renderer/Renderer.cpp, where cabinet displacement is + /// represented by shifting the rendered view rather than moving every table + /// object. VPE applies the offset to game cameras so authoring/editor cameras + /// are not disturbed. + /// + [DefaultExecutionOrder(10000)] + public sealed class VisualNudgeComponent : MonoBehaviour + { + [NonSerialized] private readonly Dictionary _appliedOffsets = new(); + [NonSerialized] private readonly List _staleCameras = new(); + [NonSerialized] private PhysicsEngine _physicsEngine; + [NonSerialized] private PlayfieldComponent _playfield; + [NonSerialized] private float2 _cabinetOffset; + [NonSerialized] private float _strength = 1f; + + /// + /// Connects this component to physics telemetry and sets the visual strength. + /// + internal void Configure(PhysicsEngine physicsEngine, float strength) + { + _physicsEngine = physicsEngine; + _strength = math.clamp(strength, 0f, 2f); + if (_playfield == null && _physicsEngine != null) { + _playfield = _physicsEngine.GetComponentInParent()?.GetComponentInChildren(); + } + } + + /// + /// Updates the latest cabinet-space offset produced by the physics thread. + /// + internal void SetCabinetOffset(float2 cabinetOffset) + { + _cabinetOffset = cabinetOffset; + } + + private void LateUpdate() + { + var desiredOffset = -WorldCabinetOffset() * _strength; + ApplyToGameCameras(desiredOffset); + } + + private void OnDisable() => RestoreAll(); + + private void OnDestroy() => RestoreAll(); + + private Vector3 WorldCabinetOffset() + { + var localOffset = new Vector3(_cabinetOffset.x, 0f, -_cabinetOffset.y); + return _playfield != null ? _playfield.transform.TransformVector(localOffset) : localOffset; + } + + /// + /// Applies the desired offset to all game cameras and restores cameras that + /// disappeared from the active list. + /// + private void ApplyToGameCameras(Vector3 desiredOffset) + { + _staleCameras.Clear(); + foreach (var camera in _appliedOffsets.Keys) { + _staleCameras.Add(camera); + } + + foreach (var camera in Camera.allCameras) { + if (!ShouldOffset(camera)) { + continue; + } + _staleCameras.Remove(camera); + ApplyToCamera(camera, desiredOffset); + } + + foreach (var camera in _staleCameras) { + RestoreCamera(camera); + } + _staleCameras.Clear(); + } + + private static bool ShouldOffset(Camera camera) + { + return camera != null + && camera.enabled + && camera.cameraType == CameraType.Game; + } + + /// + /// Replaces this frame's previous offset with the new one. + /// + /// + /// The previous offset is subtracted first so animated camera rigs, head + /// tracking, or other camera scripts can still move the camera normally + /// between VPE visual nudge updates. + /// + private void ApplyToCamera(Camera camera, Vector3 desiredOffset) + { + if (_appliedOffsets.TryGetValue(camera, out var previousOffset)) { + camera.transform.position -= previousOffset; + } + + if (desiredOffset.sqrMagnitude < 1e-12f) { + _appliedOffsets.Remove(camera); + return; + } + + camera.transform.position += desiredOffset; + _appliedOffsets[camera] = desiredOffset; + } + + private void RestoreCamera(Camera camera) + { + if (camera != null && _appliedOffsets.TryGetValue(camera, out var offset)) { + camera.transform.position -= offset; + } + _appliedOffsets.Remove(camera); + } + + private void RestoreAll() + { + _staleCameras.Clear(); + foreach (var camera in _appliedOffsets.Keys) { + _staleCameras.Add(camera); + } + foreach (var camera in _staleCameras) { + RestoreCamera(camera); + } + _staleCameras.Clear(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs.meta new file mode 100644 index 000000000..4a3346cab --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/VisualNudgeComponent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1ac7ec530f7f45daa9a695d7b7c2ff1d +timeCreated: 1783190007 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs index 0d1e61e06..ce4e1479f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs @@ -17,10 +17,11 @@ using System; using System.Collections.Generic; using System.Linq; -using NLog; -using UnityEngine; -using UnityEngine.InputSystem; -using Logger = NLog.Logger; +using NLog; +using UnityEngine; +using UnityEngine.InputSystem; +using VisualPinball.Unity.Simulation; +using Logger = NLog.Logger; namespace VisualPinball.Unity { @@ -253,52 +254,61 @@ internal void RemoveWire(WireMapping wireMapping) #region Runtime - private void HandleKeyInput(object obj, InputActionChange change) - { - switch (change) { - case InputActionChange.ActionStarted: - case InputActionChange.ActionCanceled: + private void HandleKeyInput(object obj, InputActionChange change) + { + if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true) { + return; + } + + switch (change) { + case InputActionChange.ActionStarted: + case InputActionChange.ActionCanceled: 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; - } - - var device = _wireDevices[wireConfig.Device]; - var wire = device.Wire(wireConfig.DeviceItem); - if (wire != null) { - var isEnabled = change == InputActionChange.ActionStarted; - if (!wireConfig.IsDynamic) { - wire.OnChange(isEnabled); - WireStatuses[wireConfig.Id] = (isEnabled, 0); - - } else { - _gleSignals[wireConfig][isEnabled].Enqueue(Time.realtimeSinceStartup); - if (wireConfig.IsActive) { - // the dynamic wire is active, so trigger directly. - wire.OnChange(isEnabled); - WireStatuses[wireConfig.Id] = (isEnabled, -2); - - } else { - WireStatuses[wireConfig.Id] = (WireStatuses[wireConfig.Id].Item1, -1); - } - } -#if UNITY_EDITOR - RefreshUI(); -#endif - } - else { - Logger.Warn($"Unknown wire \"{wireConfig.DeviceItem}\" in wire device \"{wireConfig.Device}\"."); - } - } - } - break; - } - } - - public void HandleSwitchChange(WireDestConfig wireConfig, bool isEnabled) + DispatchInputAction(actionName, change == InputActionChange.ActionStarted); + break; + } + } + + internal void DispatchInputAction(string actionName, bool isEnabled) + { + if (_keyWireAssignments == null || !_keyWireAssignments.ContainsKey(actionName)) { + return; + } + foreach (var wireConfig in _keyWireAssignments[actionName]) { + if (wireConfig.Device == null || !_wireDevices.ContainsKey(wireConfig.Device)) { + continue; + } + + var device = _wireDevices[wireConfig.Device]; + var wire = device.Wire(wireConfig.DeviceItem); + if (wire != null) { + if (!wireConfig.IsDynamic) { + wire.OnChange(isEnabled); + WireStatuses[wireConfig.Id] = (isEnabled, 0); + + } else { + _gleSignals[wireConfig][isEnabled].Enqueue(Time.realtimeSinceStartup); + if (wireConfig.IsActive) { + // the dynamic wire is active, so trigger directly. + wire.OnChange(isEnabled); + WireStatuses[wireConfig.Id] = (isEnabled, -2); + + } else { + WireStatuses[wireConfig.Id] = (WireStatuses[wireConfig.Id].Item1, -1); + } + } +#if UNITY_EDITOR + RefreshUI(); +#endif + } + else { + Logger.Warn($"Unknown wire \"{wireConfig.DeviceItem}\" in wire device \"{wireConfig.Device}\"."); + } + } + } + + public void HandleSwitchChange(WireDestConfig wireConfig, bool isEnabled) { var device = _player.WireDevice(wireConfig.Device); if (device != null) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs index 45bbc68de..26c3c6f76 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs @@ -189,6 +189,10 @@ public static InputActionAsset GetDefaultInputActionAsset() map.AddAction(InputConstants.ActionCoinDoorPlus, InputActionType.Button); map.AddAction(InputConstants.ActionCoinDoorSelect, InputActionType.Button); map.AddAction(InputConstants.ActionSlamTilt, InputActionType.Button, "/home"); + map.AddAction(InputConstants.ActionLeftNudge, InputActionType.Button, "/z"); + map.AddAction(InputConstants.ActionRightNudge, InputActionType.Button, "/slash"); + map.AddAction(InputConstants.ActionCenterNudge, InputActionType.Button, "/space"); + map.AddAction(InputConstants.ActionTilt, InputActionType.Button, "/t"); map.AddAction(InputConstants.ActionSelfTest, InputActionType.Button); map.AddAction(InputConstants.ActionLeftAdvance, InputActionType.Button); map.AddAction(InputConstants.ActionRightAdvance, InputActionType.Button); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs new file mode 100644 index 000000000..31392aa80 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs @@ -0,0 +1,212 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Globalization; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Physical meaning of a native analog axis after it has been mapped. + /// + public enum SensorMappingKind + { + /// Position or intent input, such as a gamepad stick axis. + Position, + /// Measured cabinet velocity. + Velocity, + /// Measured cabinet acceleration. + Acceleration + } + + /// + /// Describes how one native input axis maps into a nudge sensor channel. + /// + /// + /// The mapping stores raw device identity and calibration values rather than a + /// normalized Unity input binding. Native devices such as KL25Z/Pinscape can + /// expose motion axes with device-specific centers, so the raw center must be + /// captured and applied before dead-zone, limit, and scale. + /// + public sealed class SensorMapping + { + public string DeviceId { get; set; } = string.Empty; + public int AxisId { get; set; } = -1; + public SensorMappingKind Kind { get; set; } = SensorMappingKind.Position; + public float DeadZone { get; set; } + public float Scale { get; set; } = 1f; + public float Limit { get; set; } = 1f; + public float RawCenter { get; set; } + public float RawValue { get; private set; } + public float MappedValue { get; private set; } + public long RawTimestampUsec { get; private set; } + + public bool IsMapped => !string.IsNullOrEmpty(DeviceId) && AxisId >= 0; + + /// + /// Applies center, dead-zone, limit, and scale to a raw native input value. + /// + /// The value to feed into the configured nudge channel. + public float ProcessRawValue(float rawValue, long timestampUsec) + { + RawValue = rawValue; + RawTimestampUsec = timestampUsec; + + var deadZone = math.clamp(DeadZone, 0f, 0.999f); + var value = RawValue - RawCenter; + var absValue = math.abs(value); + if (absValue <= deadZone) { + value = 0f; + } else { + value = math.sign(value) * ((absValue - deadZone) / (1f - deadZone)); + } + + // Always clamp, like the reference: a limit of 0 zeroes the output rather + // than meaning "unlimited". + var limit = math.max(0f, Limit); + value = math.clamp(value, -limit, limit); + MappedValue = value * Scale; + return MappedValue; + } + + /// + /// Serializes this mapping into the compact package/component format. + /// + public override string ToString() + { + if (!IsMapped) { + return string.Empty; + } + + var value = string.Join(";", + DeviceId, + AxisId.ToString(CultureInfo.InvariantCulture), + KindToCode(Kind), + DeadZone.ToString(CultureInfo.InvariantCulture), + Scale.ToString(CultureInfo.InvariantCulture), + Limit.ToString(CultureInfo.InvariantCulture) + ); + if (math.abs(RawCenter) > 1.0e-6f) { + value += ";" + RawCenter.ToString(CultureInfo.InvariantCulture); + } + return value; + } + + /// + /// Parses a mapping written by . + /// + /// + /// The parser accepts both the current six-value payload and the older + /// five-value form without a raw center so existing tables keep loading. + /// + public static bool TryParse(string value, out SensorMapping mapping) + { + mapping = new SensorMapping(); + if (string.IsNullOrWhiteSpace(value)) { + return false; + } + + var parts = value.Split(';'); + if (parts.Length < 6) { + return false; + } + + if (TryParseParts(parts, 6, out mapping)) { + return mapping.IsMapped; + } + if (TryParseParts(parts, 5, out mapping)) { + return mapping.IsMapped; + } + mapping = new SensorMapping(); + return false; + } + + /// + /// Parses from the end of the semicolon-delimited payload so semicolons in + /// native device ids are preserved. + /// + private static bool TryParseParts(string[] parts, int trailingValueCount, out SensorMapping mapping) + { + mapping = new SensorMapping(); + + // The device id is a free-form native path that may itself contain ';'; + // the trailing fields never do, so parse from the end and re-join whatever precedes them. + var p = parts.Length - trailingValueCount; + if (p <= 0) { + return false; + } + var deviceId = p == 1 ? parts[0] : string.Join(";", parts, 0, p); + + if (!int.TryParse(parts[p], NumberStyles.Integer, CultureInfo.InvariantCulture, out var axisId)) { + return false; + } + if (!TryCodeToKind(parts[p + 1], out var kind)) { + return false; + } + if (!float.TryParse(parts[p + 2], NumberStyles.Float, CultureInfo.InvariantCulture, out var deadZone)) { + return false; + } + if (!float.TryParse(parts[p + 3], NumberStyles.Float, CultureInfo.InvariantCulture, out var scale)) { + return false; + } + if (!float.TryParse(parts[p + 4], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit)) { + return false; + } + var rawCenter = 0f; + if (trailingValueCount > 5 && + !float.TryParse(parts[p + 5], NumberStyles.Float, CultureInfo.InvariantCulture, out rawCenter)) { + return false; + } + + mapping.DeviceId = deviceId; + mapping.AxisId = axisId; + mapping.Kind = kind; + mapping.DeadZone = math.clamp(deadZone, 0f, 0.999f); + mapping.Scale = scale; + mapping.Limit = math.max(0f, limit); + mapping.RawCenter = rawCenter; + return true; + } + + private static string KindToCode(SensorMappingKind kind) + { + return kind switch { + SensorMappingKind.Velocity => "V", + SensorMappingKind.Acceleration => "A", + _ => "P" + }; + } + + private static bool TryCodeToKind(string code, out SensorMappingKind kind) + { + switch (code) { + case "P": + kind = SensorMappingKind.Position; + return true; + case "V": + kind = SensorMappingKind.Velocity; + return true; + case "A": + kind = SensorMappingKind.Acceleration; + return true; + default: + kind = default; + return false; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs.meta new file mode 100644 index 000000000..48e5da42a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/SensorMapping.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9be1b50c0cb4ce0a8782ec7f610f0e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs index 92589c3da..cb4a26d84 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -28,6 +28,7 @@ using Newtonsoft.Json.Linq; using NLog; using UnityEngine; +using VisualPinball.Unity.Simulation; using Logger = NLog.Logger; namespace VisualPinball.Unity @@ -241,6 +242,7 @@ await ReadPackablesAsync( var tableMetadataStopwatch = Stopwatch.StartNew(); ReportProgress(progress, RuntimePackageLoadStage.RestoringTableMetadata, 0f, "Reading table metadata..."); ReadTableMetadata(); + SimulationThreadComponent.EnsureForTable(_table); ReportProgress(progress, RuntimePackageLoadStage.RestoringTableMetadata, 1f, "Table metadata ready."); tableMetadataStopwatch.Stop(); Logger.Info($"RuntimePackageReader: Read table metadata in {tableMetadataStopwatch.ElapsedMilliseconds}ms."); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet.meta new file mode 100644 index 000000000..d8f2a3881 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9fadb2b2fd334fe89fae688c7d57c910 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs new file mode 100644 index 000000000..5c7c3608e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs @@ -0,0 +1,105 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Models cabinet movement as two independent damped spring axes and exposes + /// the cabinet acceleration/displacement used by ball physics, visual nudge, + /// and the simulated plumb bob. + /// + /// + /// This is the VPE representation of VP's cabinet nudge source concept from + /// vpinball/src/physics/cabinet/NudgeHandler.*. The calibrated X/Y + /// frequencies are intentionally different: real cabinets are stiffer across + /// the cab than front-to-back. + /// + public struct CabinetPhysicsState + { + public const float DefaultKeyboardDampingRatio = 0.5f; + public const float MinKeyboardDampingRatio = 0.05f; + public const float MaxKeyboardDampingRatio = 1f; + + private const float XFrequencyHz = 9.3f; + private const float YFrequencyHz = 5.8f; + private const float XCalibratedDampingRatio = 0.052f; + private const float YCalibratedDampingRatio = 0.055f; + + public float Mass; + public DampedHarmonicOscillator X; + public DampedHarmonicOscillator Y; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + + /// + /// Creates the default cabinet oscillator with calibrated cabinet damping. + /// + public CabinetPhysicsState(float mass) : this(mass, XCalibratedDampingRatio, YCalibratedDampingRatio) + { + } + + private CabinetPhysicsState(float mass, float xDampingRatio, float yDampingRatio) + { + Mass = mass; + X = new DampedHarmonicOscillator(mass, XFrequencyHz, xDampingRatio); + Y = new DampedHarmonicOscillator(mass, YFrequencyHz, yDampingRatio); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + } + + public static CabinetPhysicsState Default => new(113f); + + /// + /// Creates the softer oscillator used for keyboard/button nudges. + /// + /// + /// Keyboard input is an intent, not a measured cabinet acceleration. The + /// damping parameter lets authors make that synthetic impulse feel sturdy + /// rather than like a long spring oscillation. + /// + public static CabinetPhysicsState Keyboard(float dampingRatio) + { + dampingRatio = math.clamp(dampingRatio, MinKeyboardDampingRatio, MaxKeyboardDampingRatio); + return new CabinetPhysicsState(113f, dampingRatio, dampingRatio); + } + + /// + /// Advances both cabinet axes by one physics millisecond using the supplied + /// force in Newtons. + /// + public void StepOneMillisecond(float2 force) + { + X.StepOneMillisecond(force.x); + Y.StepOneMillisecond(force.y); + CabinetAcceleration = new float2(X.Acceleration, Y.Acceleration); + CabinetOffset = new float2(X.Displacement, Y.Displacement); + } + + /// + /// Clears displacement, velocity, and acceleration without changing the + /// oscillator calibration. + /// + public void Reset() + { + X.Reset(); + Y.Reset(); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs.meta new file mode 100644 index 000000000..c0a66edcc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/CabinetPhysicsState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 96b1b5faeaf24a9f81a0959261fbb4f4 +timeCreated: 1783190001 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs new file mode 100644 index 000000000..f7c3a3929 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs @@ -0,0 +1,85 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// One-dimensional mass/spring/damper integrator used for cabinet motion. + /// + /// + /// It stores physical units directly: force in Newtons, displacement in + /// meters, velocity in meters per second, and acceleration in meters per + /// second squared. The physics thread calls it at the fixed VP/VPE 1 ms step. + /// + public struct DampedHarmonicOscillator + { + public float Mass; + public float Omega0; + public float SpringConstant; + public float DampingCoefficient; + public float Displacement; + public float Velocity; + public float Acceleration; + + /// + /// Creates an oscillator from mass, natural frequency, and damping ratio. + /// + public DampedHarmonicOscillator(float mass, float frequencyHz, float dampingRatio) + { + Mass = mass; + Omega0 = 2f * math.PI * frequencyHz; + SpringConstant = mass * Omega0 * Omega0; + DampingCoefficient = 2f * dampingRatio * mass * Omega0; + Displacement = 0f; + Velocity = 0f; + Acceleration = 0f; + } + + /// + /// Advances the oscillator by VPE's fixed one millisecond physics step. + /// + public void StepOneMillisecond(float force) + { + Step(force, 0.001f); + } + + /// + /// Applies one explicit Euler integration step. + /// + /// + /// The cabinet nudge model is intentionally simple and cheap; it runs in the + /// same tight loop as ball physics and does not need a general solver. + /// + public void Step(float force, float deltaTime) + { + Acceleration = (force - DampingCoefficient * Velocity - SpringConstant * Displacement) / Mass; + Velocity += Acceleration * deltaTime; + Displacement += Velocity * deltaTime; + } + + /// + /// Returns the oscillator to rest while preserving its mass and coefficients. + /// + public void Reset() + { + Displacement = 0f; + Velocity = 0f; + Acceleration = 0f; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs.meta new file mode 100644 index 000000000..a4596d538 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/DampedHarmonicOscillator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 815f1ed2b4fd4ad8a92e66324115caca +timeCreated: 1783190000 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs new file mode 100644 index 000000000..83ba766d9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs @@ -0,0 +1,40 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + /// + /// Selects how a digital keyboard/button nudge is converted into cabinet + /// acceleration. + /// + public enum KeyboardNudgeMode + { + /// + /// Legacy VP-style instant shove/retract pulse. + /// + PushRetract = 0, + + /// + /// Legacy VP box model where a table-space offset springs back to rest. + /// + BoxModel = 1, + + /// + /// Cabinet oscillator model introduced for this nudge stack. + /// + CabModel = 2 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs.meta new file mode 100644 index 000000000..eaa836da3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeMode.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3bd56abde1f04167a4ebf4b612044bf4 +timeCreated: 1783190002 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs new file mode 100644 index 000000000..2d3646201 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs @@ -0,0 +1,300 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity +{ + /// + /// Converts script or keyboard nudge commands into cabinet acceleration and + /// displacement samples. + /// + /// + /// The and + /// paths are direct ports/adaptations + /// of VP keyboard nudge behavior from + /// vpinball/src/physics/cabinet/KeyboardNudge.*. The cabinet model path + /// keeps the same public Nudge(angle, force) shape but feeds the oscillator in + /// so keyboard nudges feel more like a + /// sturdy cabinet impulse. + /// + public struct KeyboardNudgeState + { + private const int DeactivationDelayMs = 10000; + private const int CabImpulseLengthMs = 25; + + public KeyboardNudgeMode Mode; + public float Strength; + public float CabinetDamping; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + + private float2 _pushImpulse; + private int _pushNudgeTime; + private int _pushDeactivationDelay; + + private float2 _boxVelocity; + private float2 _boxPrevVelocity; + private float2 _boxPositionVpu; + private float _boxSpring; + private float _boxDamping; + private int _boxDeactivationDelay; + + private CabinetPhysicsState _cabinet; + private FixedList512Bytes _cabImpulses; + private int _cabDeactivationDelay; + + /// + /// A short raised-cosine cabinet acceleration pulse. + /// + /// + /// The cosine window avoids a square acceleration step, which would make the + /// visual camera offset and plumb bob react with a hard discontinuity. + /// + private struct CabinetImpulse + { + public int Length; + public int Elapsed; + public float2 Acceleration; + + public CabinetImpulse(int length, float2 acceleration) + { + Length = length; + Elapsed = 0; + Acceleration = acceleration; + } + + public bool IsInProgress => Elapsed <= Length; + + public float2 CurrentAcceleration + { + get { + if (!IsInProgress) { + return float2.zero; + } + var t = (float)Elapsed / Length; + return Acceleration * 0.5f * (1f - math.cos(2f * math.PI * t)); + } + } + } + + /// + /// Creates keyboard nudge state for a table. + /// + /// Conversion model used for keyboard/button nudges. + /// Author/user strength multiplier. + /// Table nudge time used by the legacy box model. + /// Damping ratio used by the cabinet oscillator model. + public KeyboardNudgeState(KeyboardNudgeMode mode, float strength, float nudgeTime, + float cabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) + { + Mode = mode; + Strength = strength; + CabinetDamping = math.clamp(cabinetDamping, + CabinetPhysicsState.MinKeyboardDampingRatio, + CabinetPhysicsState.MaxKeyboardDampingRatio); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + _pushImpulse = float2.zero; + _pushNudgeTime = 0; + _pushDeactivationDelay = 0; + _boxVelocity = float2.zero; + _boxPrevVelocity = float2.zero; + _boxPositionVpu = float2.zero; + _boxSpring = 0f; + _boxDamping = 0f; + _boxDeactivationDelay = 0; + _cabinet = CabinetPhysicsState.Keyboard(CabinetDamping); + _cabImpulses = default; + _cabDeactivationDelay = 0; + + ConfigureBoxModel(nudgeTime); + } + + public bool IsActive => Mode switch { + KeyboardNudgeMode.PushRetract => _pushDeactivationDelay > 0, + KeyboardNudgeMode.BoxModel => _boxDeactivationDelay > 0, + _ => _cabDeactivationDelay > 0, + }; + + /// + /// Rebuilds the mode-specific state after runtime configuration changes. + /// + public void Configure(KeyboardNudgeMode mode, float strength, float nudgeTime, float cabinetDamping) + { + this = new KeyboardNudgeState(mode, strength, nudgeTime, cabinetDamping); + } + + /// + /// Updates only the strength scalar while preserving in-flight state. + /// + public void SetStrength(float strength) + { + Strength = strength; + } + + /// + /// Starts a new keyboard/button nudge from the VP script-facing angle/force + /// convention. + /// + public void Nudge(float angleDeg, float force) + { + switch (Mode) { + case KeyboardNudgeMode.PushRetract: + PushRetractNudge(angleDeg, force); + break; + case KeyboardNudgeMode.BoxModel: + BoxModelNudge(angleDeg, force); + break; + default: + CabModelNudge(angleDeg, force); + break; + } + } + + /// + /// Advances whichever keyboard nudge model is active by one physics tick. + /// + public void StepOneMillisecond() + { + switch (Mode) { + case KeyboardNudgeMode.PushRetract: + StepPushRetract(); + break; + case KeyboardNudgeMode.BoxModel: + StepBoxModel(); + break; + default: + StepCabModel(); + break; + } + } + + private void ConfigureBoxModel(float nudgeTime) + { + nudgeTime = math.max(0.001f, nudgeTime); + const float dampingRatio = 0.5f; + _boxSpring = math.PI * math.PI / (nudgeTime * nudgeTime * (1f - dampingRatio * dampingRatio)); + _boxDamping = dampingRatio * 2f * math.sqrt(_boxSpring); + } + + private void PushRetractNudge(float angleDeg, float force) + { + _pushDeactivationDelay = DeactivationDelayMs; + if (_pushNudgeTime != 0) { + return; + } + + var angle = math.radians(angleDeg); + _pushImpulse = new float2(math.sin(angle), -math.cos(angle)) * (Strength * force); + _pushNudgeTime = 100; + } + + private void StepPushRetract() + { + if (_pushDeactivationDelay > 0) { + _pushDeactivationDelay--; + } + + if (_pushNudgeTime != 0) { + _pushNudgeTime--; + if (_pushNudgeTime == 95) { + CabinetAcceleration = new float2(-_pushImpulse.x * 2f, _pushImpulse.y * 2f) + * (1f / PhysicsConstants.PhysFactor) + * (1f / PhysicsConstants.Ms2ToVpuVpt2); + } else if (_pushNudgeTime == 90) { + CabinetAcceleration = new float2(_pushImpulse.x, -_pushImpulse.y) + * (1f / PhysicsConstants.PhysFactor) + * (1f / PhysicsConstants.Ms2ToVpuVpt2); + } else { + CabinetAcceleration = float2.zero; + } + } else { + CabinetAcceleration = float2.zero; + } + + var attenuation = math.pow(_pushNudgeTime * 0.01f, 2f); + CabinetOffset = new float2(_pushImpulse.x, -_pushImpulse.y) * attenuation * PhysicsConstants.VpuToM; + } + + private void BoxModelNudge(float angleDeg, float force) + { + _boxDeactivationDelay = DeactivationDelayMs; + var angle = math.radians(angleDeg); + _boxVelocity += new float2(math.sin(angle), -math.cos(angle)) * (Strength * force); + } + + private void StepBoxModel() + { + if (_boxDeactivationDelay > 0) { + _boxDeactivationDelay--; + } + + var force = -_boxSpring * _boxPositionVpu - _boxDamping * _boxVelocity; + _boxVelocity += PhysicsConstants.PhysFactor * force; + _boxPositionVpu += PhysicsConstants.PhysFactor * _boxVelocity; + CabinetOffset = _boxPositionVpu * PhysicsConstants.VpuToM; + CabinetAcceleration = (_boxVelocity - _boxPrevVelocity) + * (1f / PhysicsConstants.PhysFactor) + * (1f / PhysicsConstants.Ms2ToVpuVpt2); + _boxPrevVelocity = _boxVelocity; + } + + private void CabModelNudge(float angleDeg, float force) + { + _cabDeactivationDelay = DeactivationDelayMs; + var angle = math.radians(angleDeg); + var acceleration = new float2(math.sin(angle), -math.cos(angle)) * (force * Strength * 6f); + if (_cabImpulses.Length < _cabImpulses.Capacity) { + _cabImpulses.Add(new CabinetImpulse(CabImpulseLengthMs, acceleration)); + } + } + + private void StepCabModel() + { + if (_cabDeactivationDelay > 0) { + _cabDeactivationDelay--; + } + + var impulse = float2.zero; + for (var i = 0; i < _cabImpulses.Length;) { + var cabImpulse = _cabImpulses[i]; + cabImpulse.Elapsed++; + if (cabImpulse.IsInProgress) { + impulse += cabImpulse.CurrentAcceleration; + _cabImpulses[i] = cabImpulse; + i++; + } else { + RemoveCabImpulseAt(i); + } + } + + _cabinet.StepOneMillisecond(_cabinet.Mass * impulse); + CabinetAcceleration = _cabinet.CabinetAcceleration; + CabinetOffset = _cabinet.CabinetOffset; + } + + private void RemoveCabImpulseAt(int index) + { + for (var i = index + 1; i < _cabImpulses.Length; i++) { + _cabImpulses[i - 1] = _cabImpulses[i]; + } + _cabImpulses.Length--; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs.meta new file mode 100644 index 000000000..5914741ac --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/KeyboardNudgeState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ab7a79c1d27c46cc964e5251a0ae99a5 +timeCreated: 1783190003 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs new file mode 100644 index 000000000..f30280795 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs @@ -0,0 +1,473 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Learns the scale that converts a raw cabinet accelerometer channel into + /// the same units as a raw cabinet velocity channel. + /// + /// + /// Adapted from VP's + /// vpinball/src/physics/cabinet/MotionGainCalibratorAxis.h. The + /// calibrator only accepts motion segments that leave and return to rest, + /// because those segments let us compare measured velocity against integrated + /// acceleration without depending on an external ground-truth position. + /// + public struct MotionGainCalibratorAxis + { + /// + /// Tunables used to decide whether a motion segment is useful and how much + /// accepted segments influence the running gain estimate. + /// + public struct Config + { + public float InitialGain; + public ulong MinSegmentDurationUs; + public ulong MaxSegmentDurationUs; + public int MinSampleCount; + public float MinVelocityPeakToPeak; + public float MinIntegratedAccelPeakToPeak; + public float MinRegressionDenominator; + public float ForgettingFactor; + public float MinGain; + public float MaxGain; + public byte ExcludeSegmentEndpointsFromFit; + public byte UseTriangularWeights; + public float Epsilon; + + public static Config Default => new() { + InitialGain = 1.0f, + MinSegmentDurationUs = 30000, + MaxSegmentDurationUs = 2000000, + MinSampleCount = 8, + MinVelocityPeakToPeak = 0.02f, + MinIntegratedAccelPeakToPeak = 0.002f, + MinRegressionDenominator = 1.0e-6f, + ForgettingFactor = 0.995f, + MinGain = 0.001f, + MaxGain = 1000.0f, + ExcludeSegmentEndpointsFromFit = 1, + UseTriangularWeights = 1, + Epsilon = 1.0e-9f + }; + } + + /// + /// Diagnostics for the last completed calibration segment. + /// + public struct SegmentResult + { + public byte Accepted; + public ulong DurationUs; + public int SampleCount; + public float SegmentGain; + public float SegmentQuality; + public float SegmentResidualRms; + public float VelocityPeakToPeak; + public float IntegratedAccelPeakToPeak; + public float SegmentNumerator; + public float SegmentDenominator; + } + + private struct Sample + { + public ulong TimeUs; + public float Velocity; + public float Acceleration; + } + + private Config _config; + private byte _segmentActive; + private ulong _segmentStartUs; + private FixedList4096Bytes _samples; + private float _gain; + private double _accumulatedNumerator; + private double _accumulatedDenominator; + private int _startedSegmentCount; + private int _acceptedSegmentCount; + private int _rejectedSegmentCount; + private ulong _totalAcceptedDurationUs; + private SegmentResult _lastResult; + private float _globalConfidence; + + /// + /// Creates a calibrator with the supplied acceptance thresholds and initial + /// gain. + /// + public MotionGainCalibratorAxis(Config config) + { + _config = config; + _segmentActive = 0; + _segmentStartUs = 0; + _samples = default; + _gain = config.InitialGain; + _accumulatedNumerator = 0.0; + _accumulatedDenominator = 0.0; + _startedSegmentCount = 0; + _acceptedSegmentCount = 0; + _rejectedSegmentCount = 0; + _totalAcceptedDurationUs = 0; + _lastResult = default; + _globalConfidence = 0f; + } + + /// + /// Calibrator configured with defaults tuned for cabinet nudge sensors. + /// + public static MotionGainCalibratorAxis Default => new(Config.Default); + public bool IsSegmentActive => _segmentActive != 0; + public float Gain => _gain; + public int StartedSegmentCount => _startedSegmentCount; + public int AcceptedSegmentCount => _acceptedSegmentCount; + public int RejectedSegmentCount => _rejectedSegmentCount; + public ulong TotalAcceptedDurationUs => _totalAcceptedDurationUs; + public float GlobalConfidence => _globalConfidence; + public SegmentResult LastResult => _lastResult; + + /// + /// Applies new acceptance thresholds while preserving the already learned + /// gain. + /// + public void Configure(Config config) + { + _config = config; + _gain = math.clamp(_gain, _config.MinGain, _config.MaxGain); + } + + /// + /// Clears learned gain, confidence, segment counters, and any in-progress + /// segment. + /// + public void Reset() + { + _segmentActive = 0; + _samples.Clear(); + _gain = _config.InitialGain; + _accumulatedNumerator = 0.0; + _accumulatedDenominator = 0.0; + _startedSegmentCount = 0; + _acceptedSegmentCount = 0; + _rejectedSegmentCount = 0; + _totalAcceptedDurationUs = 0; + _lastResult = default; + _globalConfidence = 0f; + } + + /// + /// Converts a raw acceleration sample using the current learned gain. + /// + public float ScaleAcceleration(float rawAcceleration) => _gain * rawAcceleration; + + /// + /// Converts a raw velocity sample into acceleration-equivalent units for + /// Kalman updates. + /// + public float ScaleVelocityToAccelerationUnits(float rawVelocity) + { + return math.abs(_gain) <= _config.Epsilon ? 0f : rawVelocity / _gain; + } + + /// + /// Starts collecting samples for one candidate motion segment. + /// + public void StartSegment(ulong timeUs) + { + _segmentActive = 1; + _samples.Clear(); + _startedSegmentCount++; + _segmentStartUs = timeUs; + } + + /// + /// Adds a raw velocity/acceleration sample to the active segment. + /// + /// true when the sample was accepted into the segment. + public bool AddSample(ulong timeUs, float rawVelocity, float rawAcceleration) + { + if (!IsSegmentActive) { + return false; + } + if (_samples.Length > 0 && timeUs <= _samples[_samples.Length - 1].TimeUs) { + return false; + } + if (_samples.Length >= _samples.Capacity) { + // The regression assumes segments that begin and end at rest (the endpoint + // detrend removes the baseline between them), so the segment must not be + // split here. Halve the sample resolution instead: the buffer covers the + // full 2s max segment duration at progressively coarser (but timestamped, + // hence still correctly integrated) steps. + CompactSamples(); + } + + _samples.Add(new Sample { + TimeUs = timeUs, + Velocity = rawVelocity, + Acceleration = rawAcceleration + }); + return true; + } + + /// + /// Reduces sample density when the fixed-size buffer fills. + /// + private void CompactSamples() + { + var compacted = 0; + var i = 0; + for (; i + 1 < _samples.Length; i += 2) { + var a = _samples[i]; + var b = _samples[i + 1]; + _samples[compacted++] = new Sample { + TimeUs = a.TimeUs + (b.TimeUs - a.TimeUs) / 2, + Velocity = 0.5f * (a.Velocity + b.Velocity), + Acceleration = 0.5f * (a.Acceleration + b.Acceleration) + }; + } + if (i < _samples.Length) { + _samples[compacted++] = _samples[i]; + } + _samples.Length = compacted; + } + + /// + /// Evaluates the active segment and folds accepted data into the running + /// gain estimate. + /// + /// true when the segment passed the quality gates. + public bool EndSegment() + { + if (!IsSegmentActive) { + _lastResult = default; + return false; + } + + _segmentActive = 0; + _lastResult = EvaluateCurrentSegment(); + + if (_lastResult.Accepted == 0) { + _rejectedSegmentCount++; + _samples.Clear(); + return false; + } + + var lambda = (double)math.clamp(_config.ForgettingFactor, 0f, 1f); + _accumulatedNumerator = lambda * _accumulatedNumerator + _lastResult.SegmentNumerator; + _accumulatedDenominator = lambda * _accumulatedDenominator + _lastResult.SegmentDenominator; + + if (_accumulatedDenominator > _config.Epsilon) { + var gain = _accumulatedNumerator / _accumulatedDenominator; + _gain = math.clamp((float)gain, _config.MinGain, _config.MaxGain); + } + + _acceptedSegmentCount++; + _totalAcceptedDurationUs += _lastResult.DurationUs; + _globalConfidence = ComputeGlobalConfidence(); + _samples.Clear(); + return true; + } + + /// + /// Estimates how trustworthy the accumulated gain is from accepted segment + /// count, observed duration, and regression strength. + /// + private float ComputeGlobalConfidence() + { + if (_acceptedSegmentCount == 0) { + return 0f; + } + + var segFactor = 1f - math.exp(-0.25f * _acceptedSegmentCount); + var durationS = _totalAcceptedDurationUs * 1.0e-6f; + var durationFactor = 1f - math.exp(-durationS * 0.5f); + var denomFactor = 1f - math.exp(-(float)_accumulatedDenominator * 0.25f); + return math.clamp(0.40f * segFactor + 0.35f * durationFactor + 0.25f * denomFactor, 0f, 1f); + } + + /// + /// Fits integrated acceleration to velocity for the current rest-to-rest + /// segment. + /// + /// + /// Both traces are detrended between their endpoints so small DC offsets + /// and residual drift do not dominate the gain estimate. This mirrors the VP + /// implementation and is the reason segments must not be split when the + /// sample buffer fills. + /// + private SegmentResult EvaluateCurrentSegment() + { + var result = new SegmentResult { + SegmentGain = 1f + }; + var n = _samples.Length; + result.SampleCount = n; + if (n < _config.MinSampleCount) { + return result; + } + + var startUs = _samples[0].TimeUs; + var endUs = _samples[n - 1].TimeUs; + var durationUs = endUs - startUs; + result.DurationUs = durationUs; + if (durationUs < _config.MinSegmentDurationUs || durationUs > _config.MaxSegmentDurationUs) { + return result; + } + + var tNorm = default(FixedList4096Bytes); + var velocity = default(FixedList4096Bytes); + var velocityDetrended = default(FixedList4096Bytes); + var integratedAcceleration = default(FixedList4096Bytes); + var integratedAccelerationDetrended = default(FixedList4096Bytes); + tNorm.Length = n; + velocity.Length = n; + velocityDetrended.Length = n; + integratedAcceleration.Length = n; + integratedAccelerationDetrended.Length = n; + + var totalDurationS = durationUs * 1.0e-6; + if (totalDurationS <= 0.0) { + return result; + } + + for (var i = 0; i < n; i++) { + var relUs = (double)(_samples[i].TimeUs - startUs); + tNorm[i] = (float)(relUs / durationUs); + velocity[i] = _samples[i].Velocity; + } + + integratedAcceleration[0] = 0f; + for (var i = 1; i < n; i++) { + var dt = (_samples[i].TimeUs - _samples[i - 1].TimeUs) * 1.0e-6f; + var a0 = _samples[i - 1].Acceleration; + var a1 = _samples[i].Acceleration; + integratedAcceleration[i] = integratedAcceleration[i - 1] + 0.5f * (a0 + a1) * dt; + } + + var vel0 = velocity[0]; + var vel1 = velocity[n - 1]; + var int0 = integratedAcceleration[0]; + var int1 = integratedAcceleration[n - 1]; + for (var i = 0; i < n; i++) { + velocityDetrended[i] = velocity[i] - math.lerp(vel0, vel1, tNorm[i]); + integratedAccelerationDetrended[i] = integratedAcceleration[i] - math.lerp(int0, int1, tNorm[i]); + } + + result.VelocityPeakToPeak = PeakToPeak(velocityDetrended, n); + result.IntegratedAccelPeakToPeak = PeakToPeak(integratedAccelerationDetrended, n); + if (result.VelocityPeakToPeak < _config.MinVelocityPeakToPeak + || result.IntegratedAccelPeakToPeak < _config.MinIntegratedAccelPeakToPeak) { + return result; + } + + var numerator = 0.0; + var denominator = 0.0; + var fitBegin = _config.ExcludeSegmentEndpointsFromFit != 0 && n >= 3 ? 1 : 0; + var fitEnd = _config.ExcludeSegmentEndpointsFromFit != 0 && n >= 3 ? n - 1 : n; + + for (var i = fitBegin; i < fitEnd; i++) { + var x = integratedAccelerationDetrended[i]; + var y = velocityDetrended[i]; + var w = ComputeSampleWeight(tNorm[i]); + numerator += (double)w * x * y; + denominator += (double)w * x * x; + } + + result.SegmentNumerator = (float)numerator; + result.SegmentDenominator = (float)denominator; + if (denominator < _config.MinRegressionDenominator) { + return result; + } + + var segmentGain = numerator / denominator; + result.SegmentGain = (float)segmentGain; + if (!IsFinite(result.SegmentGain)) { + return result; + } + + var weightedResidual2 = 0.0; + var weightedCount = 0.0; + for (var i = fitBegin; i < fitEnd; i++) { + var x = integratedAccelerationDetrended[i]; + var y = velocityDetrended[i]; + var w = ComputeSampleWeight(tNorm[i]); + var r = y - segmentGain * x; + weightedResidual2 += (double)w * r * r; + weightedCount += w; + } + if (weightedCount <= _config.Epsilon) { + return result; + } + + result.SegmentResidualRms = (float)math.sqrt((float)(weightedResidual2 / weightedCount)); + var signalScale = math.max(result.VelocityPeakToPeak, _config.Epsilon); + var normalizedResidual = result.SegmentResidualRms / signalScale; + result.SegmentQuality = 1f - math.clamp(normalizedResidual * 2f, 0f, 1f); + + if (!IsFinite(result.SegmentQuality)) { + return result; + } + if (result.SegmentGain < _config.MinGain || result.SegmentGain > _config.MaxGain) { + return result; + } + if (result.SegmentQuality <= 0.05f) { + return result; + } + + result.Accepted = 1; + return result; + } + + /// + /// Returns the optional triangular regression weight for a normalized sample + /// time. + /// + /// + /// Center weighting favors the part of the nudge where the signal is + /// strongest and deemphasizes endpoints that are most affected by rest + /// detection timing. + /// + private float ComputeSampleWeight(float tNorm) + { + if (_config.UseTriangularWeights == 0) { + return 1f; + } + var d = math.abs(2f * tNorm - 1f); + return math.max(0f, 1f - d); + } + + private static float PeakToPeak(FixedList4096Bytes data, int count) + { + if (count == 0) { + return 0f; + } + var min = data[0]; + var max = data[0]; + for (var i = 1; i < count; i++) { + min = math.min(min, data[i]); + max = math.max(max, data[i]); + } + return max - min; + } + + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs.meta new file mode 100644 index 000000000..b68890133 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionGainCalibratorAxis.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c9e90cf258c24182b3a9562570a629a5 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs new file mode 100644 index 000000000..7ad7d0c0f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs @@ -0,0 +1,550 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// One-dimensional Kalman filter for cabinet position, velocity, acceleration, + /// and slow sensor bias. + /// + /// + /// Adapted from VP's + /// vpinball/src/physics/cabinet/MotionKalmanAxis.h. Cabinet devices can + /// provide velocity, acceleration, or both, often with independent drift. The + /// five-state filter lets VPE fuse those measurements while keeping estimated + /// sensor bias separate from physical cabinet motion. + /// + public struct MotionKalmanAxis + { + private const int StatePosition = 0; + private const int StateVelocity = 1; + private const int StateAcceleration = 2; + private const int StateVelocityBias = 3; + private const int StateAccelerationBias = 4; + private const int StateCount = 5; + + /// + /// Filter covariance and timing parameters. + /// + public struct Config + { + public float ProcessJerkVariance; + public float VelocityBiasProcessVariance; + public float AccelerationBiasProcessVariance; + public float VelocityMeasurementVariance; + public float AccelerationMeasurementVariance; + public float ZeroPositionMeasurementVariance; + public float ZeroVelocityMeasurementVariance; + public float ZeroAccelerationMeasurementVariance; + public float InitialPositionVariance; + public float InitialVelocityVariance; + public float InitialAccelerationVariance; + public float InitialVelocityBiasVariance; + public float InitialAccelerationBiasVariance; + public float BiasMeanReversionTimeS; + public float MinDt; + public float MaxDt; + + public static Config Default => new() { + ProcessJerkVariance = 800.0f, + VelocityBiasProcessVariance = 0.0001f, + AccelerationBiasProcessVariance = 0.0001f, + VelocityMeasurementVariance = 0.05f * 0.05f, + AccelerationMeasurementVariance = 0.40f * 0.40f, + ZeroPositionMeasurementVariance = 0.0005f * 0.0005f, + ZeroVelocityMeasurementVariance = 0.01f * 0.01f, + ZeroAccelerationMeasurementVariance = 0.10f * 0.10f, + InitialPositionVariance = 1.0e-4f, + InitialVelocityVariance = 1.0f, + InitialAccelerationVariance = 25.0f, + InitialVelocityBiasVariance = 0.01f, + InitialAccelerationBiasVariance = 0.01f, + BiasMeanReversionTimeS = 5.0f, + MinDt = 1.0e-6f, + MaxDt = 0.001f + }; + } + + private Config _config; + private byte _initialized; + private ulong _timeUs; + private Vector5f _state; + private Matrix5f _covariance; + + /// + /// Creates an uninitialized filter using the supplied covariance settings. + /// + public MotionKalmanAxis(Config config) + { + _config = config; + _initialized = 0; + _timeUs = 0; + _state = default; + _covariance = default; + _state.SetZero(); + _covariance.SetZero(); + } + + /// + /// Filter configured with defaults tuned for nudge sensor fusion. + /// + public static MotionKalmanAxis Default => new(Config.Default); + + public bool IsInitialized => _initialized != 0; + public ulong TimeUs => _timeUs; + public float Position => _state[StatePosition]; + public float Velocity => _state[StateVelocity]; + public float Acceleration => _state[StateAcceleration]; + public float VelocityBias => _state[StateVelocityBias]; + public float AccelerationBias => _state[StateAccelerationBias]; + public float BiasedVelocity => Velocity + VelocityBias; + public float BiasedAcceleration => Acceleration + AccelerationBias; + + /// + /// Applies new covariance settings without altering the current state. + /// + public void Configure(Config config) + { + _config = config; + } + + /// + /// Initializes or reinitializes the filter at a known state and timestamp. + /// + public void Reset(ulong timeUs, float position = 0f, float velocity = 0f, float acceleration = 0f, + float velocityBias = 0f, float accelerationBias = 0f) + { + _initialized = 1; + _timeUs = timeUs; + _state.Set(position, velocity, acceleration, velocityBias, accelerationBias); + _covariance.SetZero(); + _covariance[StatePosition, StatePosition] = _config.InitialPositionVariance; + _covariance[StateVelocity, StateVelocity] = _config.InitialVelocityVariance; + _covariance[StateAcceleration, StateAcceleration] = _config.InitialAccelerationVariance; + _covariance[StateVelocityBias, StateVelocityBias] = _config.InitialVelocityBiasVariance; + _covariance[StateAccelerationBias, StateAccelerationBias] = _config.InitialAccelerationBiasVariance; + } + + /// + /// Replaces the state vector while preserving timestamp and covariance. + /// + public void SetState(float position, float velocity, float acceleration, float velocityBias, float accelerationBias) + { + _state.Set(position, velocity, acceleration, velocityBias, accelerationBias); + } + + /// + /// Advances the prediction model to the requested timestamp. + /// + /// + /// Prediction is split into small steps so unusually sparse or delayed input + /// samples do not create a single large covariance jump. + /// + public void PredictTo(ulong timeUs) + { + if (!IsInitialized || timeUs <= _timeUs) { + return; + } + + var remainingDt = (timeUs - _timeUs) * 1.0e-6f; + while (remainingDt > 0f) { + var dt = math.min(remainingDt, _config.MaxDt); + PredictStep(dt); + remainingDt -= dt; + } + _timeUs = timeUs; + } + + /// + /// Fuses a measured velocity sample. + /// + /// + /// The measurement model observes velocity plus velocity bias; keeping the + /// bias term explicit lets the filter learn slow sensor drift instead of + /// feeding it into cabinet displacement. + /// + public void UpdateVelocity(ulong timeUs, float velocity) + { + if (!IsInitialized) { + Reset(timeUs, 0f, 0f, 0f, velocity, 0f); + _covariance[StateVelocityBias, StateVelocityBias] = _config.VelocityMeasurementVariance; + return; + } + + PredictTo(timeUs); + var h = Vector5f.Zero; + h[StateVelocity] = 1f; + h[StateVelocityBias] = 1f; + UpdateScalarMeasurement(h, velocity, _config.VelocityMeasurementVariance); + } + + /// + /// Fuses a measured acceleration sample. + /// + /// + /// The measurement observes acceleration plus acceleration bias for the same + /// reason as velocity: real cabinet accelerometers tend to have small DC + /// offsets that should not become permanent cabinet force. + /// + public void UpdateAcceleration(ulong timeUs, float acceleration) + { + if (!IsInitialized) { + Reset(timeUs, 0f, 0f, 0f, 0f, acceleration); + _covariance[StateAccelerationBias, StateAccelerationBias] = _config.AccelerationMeasurementVariance; + return; + } + + PredictTo(timeUs); + var h = Vector5f.Zero; + h[StateAcceleration] = 1f; + h[StateAccelerationBias] = 1f; + UpdateScalarMeasurement(h, acceleration, _config.AccelerationMeasurementVariance); + } + + /// + /// Pulls the filter back toward rest when the synchronized sensor channels + /// indicate the cabinet has settled. + /// + /// + /// These pseudo-measurements are what keep long-running tables from slowly + /// drifting across the playfield after many tiny integrated errors. + /// + public void UpdateRestConstraints(ulong timeUs, bool applyPositionConstraint = true, + bool applyVelocityConstraint = true, bool applyAccelerationConstraint = true) + { + if (!IsInitialized) { + return; + } + + PredictTo(timeUs); + + if (applyVelocityConstraint) { + var h = Vector5f.Zero; + h[StateVelocity] = 1f; + UpdateScalarMeasurement(h, 0f, _config.ZeroVelocityMeasurementVariance); + } + if (applyAccelerationConstraint) { + var h = Vector5f.Zero; + h[StateAcceleration] = 1f; + UpdateScalarMeasurement(h, 0f, _config.ZeroAccelerationMeasurementVariance); + } + if (applyPositionConstraint) { + var h = Vector5f.Zero; + h[StatePosition] = 1f; + UpdateScalarMeasurement(h, 0f, _config.ZeroPositionMeasurementVariance); + } + } + + /// + /// Runs one bounded prediction step. + /// + private void PredictStep(float dt) + { + dt = math.max(dt, _config.MinDt); + var f = BuildTransitionMatrix(dt); + var ft = Transposed(f); + var q = BuildProcessNoiseMatrix(dt); + _state = Mul(f, _state); + _covariance = Add(Mul(Mul(f, _covariance), ft), q); + Symmetrize(ref _covariance); + } + + /// + /// Builds the constant-acceleration state transition matrix. + /// + /// + /// Bias terms decay toward zero with a configurable mean-reversion time so + /// stale bias estimates fade if the sensor behavior changes. + /// + private Matrix5f BuildTransitionMatrix(float dt) + { + var dt2 = dt * dt; + var f = Matrix5f.Identity; + f[StatePosition, StateVelocity] = dt; + f[StatePosition, StateAcceleration] = 0.5f * dt2; + f[StateVelocity, StateAcceleration] = dt; + + var tau = _config.BiasMeanReversionTimeS; + var alpha = tau > 0f ? math.exp(-dt / tau) : 1f; + f[StateVelocityBias, StateVelocityBias] = alpha; + f[StateAccelerationBias, StateAccelerationBias] = alpha; + return f; + } + + /// + /// Builds process noise for jerk-driven motion plus independent bias drift. + /// + private Matrix5f BuildProcessNoiseMatrix(float dt) + { + var qj = _config.ProcessJerkVariance; + var qbv = _config.VelocityBiasProcessVariance; + var qba = _config.AccelerationBiasProcessVariance; + var dt2 = dt * dt; + var dt3 = dt2 * dt; + var dt4 = dt3 * dt; + var dt5 = dt4 * dt; + + var q = Matrix5f.Zero; + q[StatePosition, StatePosition] = qj * (dt5 / 20f); + q[StatePosition, StateVelocity] = qj * (dt4 / 8f); + q[StatePosition, StateAcceleration] = qj * (dt3 / 6f); + q[StateVelocity, StatePosition] = qj * (dt4 / 8f); + q[StateVelocity, StateVelocity] = qj * (dt3 / 3f); + q[StateVelocity, StateAcceleration] = qj * (dt2 / 2f); + q[StateAcceleration, StatePosition] = qj * (dt3 / 6f); + q[StateAcceleration, StateVelocity] = qj * (dt2 / 2f); + q[StateAcceleration, StateAcceleration] = qj * dt; + q[StateVelocityBias, StateVelocityBias] = qbv * dt; + q[StateAccelerationBias, StateAccelerationBias] = qba * dt; + return q; + } + + /// + /// Applies one scalar Kalman measurement update. + /// + /// + /// Uses the Joseph covariance form, which costs a little more math but keeps + /// the covariance matrix better behaved after many updates in single + /// precision. + /// + private void UpdateScalarMeasurement(Vector5f h, float measurement, float measurementVariance) + { + var predictedMeasurement = Dot(h, _state); + var innovation = measurement - predictedMeasurement; + + var pht = Vector5f.Zero; + for (var i = 0; i < StateCount; i++) { + var sum = 0f; + for (var j = 0; j < StateCount; j++) { + sum += _covariance[i, j] * h[j]; + } + pht[i] = sum; + } + + var s = Dot(h, pht) + measurementVariance; + if (s <= 0f) { + return; + } + + var invS = 1f / s; + var k = Vector5f.Zero; + for (var i = 0; i < StateCount; i++) { + k[i] = pht[i] * invS; + _state[i] += k[i] * innovation; + } + + var a = Matrix5f.Identity; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + a[i, j] -= k[i] * h[j]; + } + } + + var at = Transposed(a); + var apAt = Mul(Mul(a, _covariance), at); + var krKt = OuterProduct(k, k, measurementVariance); + _covariance = Add(apAt, krKt); + Symmetrize(ref _covariance); + } + + private struct Vector5f + { + private FixedList32Bytes _values; + + public static Vector5f Zero + { + get { + var value = new Vector5f(); + value.SetZero(); + return value; + } + } + + public float this[int index] + { + get { + Ensure(); + return _values[index]; + } + set { + Ensure(); + _values[index] = value; + } + } + + public void Set(float position, float velocity, float acceleration, float velocityBias, float accelerationBias) + { + Ensure(); + _values[StatePosition] = position; + _values[StateVelocity] = velocity; + _values[StateAcceleration] = acceleration; + _values[StateVelocityBias] = velocityBias; + _values[StateAccelerationBias] = accelerationBias; + } + + public void SetZero() + { + Ensure(); + for (var i = 0; i < StateCount; i++) { + _values[i] = 0f; + } + } + + private void Ensure() + { + if (_values.Length != StateCount) { + _values.Length = StateCount; + } + } + } + + private struct Matrix5f + { + private FixedList128Bytes _values; + + public static Matrix5f Zero + { + get { + var value = new Matrix5f(); + value.SetZero(); + return value; + } + } + + public static Matrix5f Identity + { + get { + var value = Zero; + for (var i = 0; i < StateCount; i++) { + value[i, i] = 1f; + } + return value; + } + } + + public float this[int row, int column] + { + get { + Ensure(); + return _values[row * StateCount + column]; + } + set { + Ensure(); + _values[row * StateCount + column] = value; + } + } + + public void SetZero() + { + Ensure(); + for (var i = 0; i < StateCount * StateCount; i++) { + _values[i] = 0f; + } + } + + private void Ensure() + { + if (_values.Length != StateCount * StateCount) { + _values.Length = StateCount * StateCount; + } + } + } + + private static Matrix5f Transposed(Matrix5f m) + { + var t = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + t[i, j] = m[j, i]; + } + } + return t; + } + + private static Matrix5f Add(Matrix5f a, Matrix5f b) + { + var r = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + r[i, j] = a[i, j] + b[i, j]; + } + } + return r; + } + + private static Matrix5f Mul(Matrix5f a, Matrix5f b) + { + var r = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + var sum = 0f; + for (var k = 0; k < StateCount; k++) { + sum += a[i, k] * b[k, j]; + } + r[i, j] = sum; + } + } + return r; + } + + private static Vector5f Mul(Matrix5f a, Vector5f v) + { + var r = Vector5f.Zero; + for (var i = 0; i < StateCount; i++) { + var sum = 0f; + for (var j = 0; j < StateCount; j++) { + sum += a[i, j] * v[j]; + } + r[i] = sum; + } + return r; + } + + private static Matrix5f OuterProduct(Vector5f a, Vector5f b, float scale) + { + var r = Matrix5f.Zero; + for (var i = 0; i < StateCount; i++) { + for (var j = 0; j < StateCount; j++) { + r[i, j] = scale * a[i] * b[j]; + } + } + return r; + } + + private static float Dot(Vector5f a, Vector5f b) + { + var sum = 0f; + for (var i = 0; i < StateCount; i++) { + sum += a[i] * b[i]; + } + return sum; + } + + private static void Symmetrize(ref Matrix5f m) + { + for (var i = 0; i < StateCount; i++) { + for (var j = i + 1; j < StateCount; j++) { + var value = 0.5f * (m[i, j] + m[j, i]); + m[i, j] = value; + m[j, i] = value; + } + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs.meta new file mode 100644 index 000000000..bbdf90b66 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/MotionKalmanAxis.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1911f9cd69a546d59ee28c47f626c787 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs new file mode 100644 index 000000000..da2f9a109 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs @@ -0,0 +1,166 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Detects a player nudge intent from an analog acceleration signal and emits + /// a short cabinet impulse. + /// + /// + /// Ported from VP's vpinball/src/physics/cabinet/NudgeIntentHandler.*. + /// The handler watches for rising peaks rather than treating every sensor + /// sample as cabinet force. That distinction matters for gamepad sticks and + /// accelerometers configured in "intent" mode, where the player is expressing + /// a shove and VP/VPE should synthesize the physical impulse. + /// + public struct NudgeIntentState + { + private const int ImpulseLengthMs = 25; + private const float ImpulseThreshold = 1.0f; + + private byte _isGamepad; + private int _impulseElapsed; + private float2 _impulse; + private ulong _time; + private float _segmentStrength; + private ulong _segmentStart; + private ulong _segmentEnd; + private byte _segmentIsPeak; + private byte _segmentImpulseSent; + private float _lastImpulseStrength; + private ulong _lastImpulseTime; + + /// + /// Creates a peak detector for either gamepad-style or cabinet-style analog + /// input. + /// + public NudgeIntentState(bool isGamepad) + { + _isGamepad = isGamepad ? (byte)1 : (byte)0; + _impulseElapsed = ImpulseLengthMs + 1; + _impulse = float2.zero; + _time = 0; + _segmentStrength = 0f; + _segmentStart = 0; + _segmentEnd = 0; + _segmentIsPeak = 0; + _segmentImpulseSent = 0; + _lastImpulseStrength = 0f; + _lastImpulseTime = 0; + } + + public bool IsImpulseInProgress => _impulseElapsed <= ImpulseLengthMs; + + /// + /// Current raised-cosine impulse acceleration, or zero when no impulse is + /// active. + /// + public float2 ImpulseAcceleration + { + get { + if (!IsImpulseInProgress) { + return float2.zero; + } + var t = (float)_impulseElapsed / ImpulseLengthMs; + return _impulse * 0.5f * (1f - math.cos(2f * math.PI * t)); + } + } + + /// + /// Advances peak tracking by one millisecond and starts/updates an impulse + /// when a valid nudge peak is detected. + /// + /// + /// Forward nudges are clamped to the "push" half of the Y axis, matching the + /// VP source: players can shove the cabinet away from themselves, but not + /// physically pull it toward themselves with the same motion. + /// + public void StepOneMillisecond(float2 nudgeAcceleration) + { + _impulseElapsed++; + _time++; + + var nudge = new float2(nudgeAcceleration.x, math.min(nudgeAcceleration.y, 0f)); + var strength = math.length(nudge); + + if (_segmentIsPeak != 0) { + if (strength > _segmentStrength) { + _segmentStrength = strength; + _segmentEnd = _time; + if (_segmentImpulseSent == 0) { + EvaluateImpulse(nudge); + } else { + var newImpulse = GetImpulseStrengthFactor() * nudge; + if (math.lengthsq(newImpulse) > math.lengthsq(_impulse)) { + _impulse = newImpulse; + } + } + } else if (strength < _segmentStrength * 0.9f) { + _lastImpulseTime = _segmentEnd; + _lastImpulseStrength = _segmentStrength; + _segmentStrength = strength; + _segmentStart = _time; + _segmentEnd = _time; + _segmentIsPeak = 0; + } + } else { + if (strength < _segmentStrength) { + _segmentStrength = strength; + _segmentEnd = _time; + } else if (strength > math.max(0.1f, _segmentStrength * 1.1f)) { + _segmentStrength = strength; + _segmentStart = _time; + _segmentEnd = _time; + _segmentIsPeak = 1; + _segmentImpulseSent = 0; + EvaluateImpulse(nudge); + } + } + } + + private float GetImpulseStrengthFactor() + { + return 1f; + } + + /// + /// Decides whether the current peak should become an impulse. + /// + /// + /// Non-gamepad sensors suppress repeated smaller peaks for 300 ms so a noisy + /// accelerometer tail does not trigger multiple cabinet shoves from one hit. + /// + private void EvaluateImpulse(float2 impulse) + { + var strengthFactor = GetImpulseStrengthFactor(); + var fireImpulse = strengthFactor * _segmentStrength > ImpulseThreshold; + if (_isGamepad == 0) { + fireImpulse &= _segmentStrength > _lastImpulseStrength || _segmentEnd - _lastImpulseTime > 300; + } + + if (!fireImpulse) { + return; + } + + _impulse = strengthFactor * impulse; + _impulseElapsed = 0; + _segmentImpulseSent = 1; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs.meta new file mode 100644 index 000000000..8b54d1627 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeIntentState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b7e00ddc2b114e9baa92364730dbeea4 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs new file mode 100644 index 000000000..b186d32f5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs @@ -0,0 +1,38 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + /// + /// Logical axis slot used when routing native analog samples into a configured + /// nudge sensor. + /// + public enum NudgeSensorChannel + { + /// Gamepad/intent position X. + X = 0, + /// Gamepad/intent position Y. + Y = 1, + /// Cabinet velocity X. + VelocityX = 2, + /// Cabinet velocity Y. + VelocityY = 3, + /// Cabinet acceleration X. + AccelerationX = 4, + /// Cabinet acceleration Y. + AccelerationY = 5 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs.meta new file mode 100644 index 000000000..edaf9bb6b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorChannel.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 739419cc7ec34eb484e55a23c46d499a +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs new file mode 100644 index 000000000..06ff6965f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs @@ -0,0 +1,169 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Quarter-turn mounting options for accelerometer boards installed in a + /// cabinet. + /// + public enum NudgeSensorMountRotation + { + [InspectorName("0 deg")] + Rotation0 = 0, + + [InspectorName("90 deg")] + Rotation90 = 1, + + [InspectorName("180 deg")] + Rotation180 = 2, + + [InspectorName("270 deg")] + Rotation270 = 3 + } + + /// + /// Converts sensor-space axes to VPE cabinet-space axes. + /// + /// + /// KL25Z/Pinscape-style boards are often mounted in whichever orientation is + /// convenient inside the cabinet. Keeping this as a pure transform lets + /// calibration, graphing, and physics all consume the same canonical X/Y + /// convention. + /// + public static class NudgeSensorMountTransform + { + /// + /// Normalizes invalid serialized enum values back to no rotation. + /// + public static NudgeSensorMountRotation NormalizeRotation(NudgeSensorMountRotation rotation) + { + return (uint)rotation <= (uint)NudgeSensorMountRotation.Rotation270 + ? rotation + : NudgeSensorMountRotation.Rotation0; + } + + /// + /// Applies mirror and rotation to a two-axis sensor vector. + /// + public static float2 Transform(float2 value, NudgeSensorMountRotation rotation, bool mirrorX) + { + if (mirrorX) { + value.x = -value.x; + } + + switch (NormalizeRotation(rotation)) { + case NudgeSensorMountRotation.Rotation90: + return new float2(-value.y, value.x); + case NudgeSensorMountRotation.Rotation180: + return -value; + case NudgeSensorMountRotation.Rotation270: + return new float2(value.y, -value.x); + default: + return value; + } + } + + /// + /// Applies the mount transform to a single channel and value. + /// + /// + /// Samples arrive one native axis at a time, so this method rotates both the + /// channel identity and its sign instead of requiring callers to assemble a + /// full vector first. + /// + public static void TransformChannel(ref NudgeSensorChannel channel, ref float value, + NudgeSensorMountRotation rotation, bool mirrorX) + { + if (!TryGetChannelAxis(channel, out var sourceX, out var group)) { + return; + } + + var basis = sourceX ? new float2(1f, 0f) : new float2(0f, 1f); + var transformed = Transform(basis, rotation, mirrorX); + var targetX = math.abs(transformed.x) > math.abs(transformed.y); + value *= targetX ? transformed.x : transformed.y; + channel = ChannelFor(group, targetX); + } + + /// + /// Swaps mapped-axis flags when the mount rotation swaps X and Y. + /// + public static void TransformMappedAxes(ref bool xMapped, ref bool yMapped, + NudgeSensorMountRotation rotation) + { + switch (NormalizeRotation(rotation)) { + case NudgeSensorMountRotation.Rotation90: + case NudgeSensorMountRotation.Rotation270: + (xMapped, yMapped) = (yMapped, xMapped); + break; + } + } + + private static bool TryGetChannelAxis(NudgeSensorChannel channel, out bool sourceX, out ChannelGroup group) + { + switch (channel) { + case NudgeSensorChannel.X: + sourceX = true; + group = ChannelGroup.Position; + return true; + case NudgeSensorChannel.Y: + sourceX = false; + group = ChannelGroup.Position; + return true; + case NudgeSensorChannel.VelocityX: + sourceX = true; + group = ChannelGroup.Velocity; + return true; + case NudgeSensorChannel.VelocityY: + sourceX = false; + group = ChannelGroup.Velocity; + return true; + case NudgeSensorChannel.AccelerationX: + sourceX = true; + group = ChannelGroup.Acceleration; + return true; + case NudgeSensorChannel.AccelerationY: + sourceX = false; + group = ChannelGroup.Acceleration; + return true; + default: + sourceX = false; + group = default; + return false; + } + } + + private static NudgeSensorChannel ChannelFor(ChannelGroup group, bool x) + { + return group switch { + ChannelGroup.Velocity => x ? NudgeSensorChannel.VelocityX : NudgeSensorChannel.VelocityY, + ChannelGroup.Acceleration => x ? NudgeSensorChannel.AccelerationX : NudgeSensorChannel.AccelerationY, + _ => x ? NudgeSensorChannel.X : NudgeSensorChannel.Y + }; + } + + private enum ChannelGroup + { + Position, + Velocity, + Acceleration + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs.meta new file mode 100644 index 000000000..d0609ad30 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorMountTransform.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd6fb25eb11e4894e99c6225c8e744f4 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs new file mode 100644 index 000000000..05a9e2576 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs @@ -0,0 +1,574 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Simulation-thread configuration for one analog nudge sensor. + /// + /// + /// This is a blittable runtime form of the Unity component/editor settings. + /// Mapping flags are stored as bytes so the struct stays job-friendly and can + /// be copied into simulation state without managed references. + /// + public struct NudgeSensorRuntimeConfig + { + public NudgeSensorType Type; + public float Strength; + public float CabinetMassKg; + public NudgeSensorMountRotation MountRotation; + public byte MountMirror; + public byte XMapped; + public byte YMapped; + public byte VelocityXMapped; + public byte VelocityYMapped; + public byte AccelerationXMapped; + public byte AccelerationYMapped; + } + + /// + /// Stores the latest value for one logical nudge channel and records how that + /// channel should be interpreted. + /// + public struct NudgePhysicsSensorState + { + public byte Mapped; + public SensorMappingKind Kind; + public float Value; + public ulong TimestampUsec; + + public bool IsMapped => Mapped != 0; + + /// + /// Enables or disables the channel and clears its last sample. + /// + public void Configure(bool mapped, SensorMappingKind kind) + { + Mapped = mapped ? (byte)1 : (byte)0; + Kind = kind; + Value = 0f; + TimestampUsec = 0; + } + + /// + /// Stores a mapped native-input value and its native timestamp. + /// + public void SetValue(float value, ulong timestampUsec) + { + Value = value; + TimestampUsec = timestampUsec; + } + } + + /// + /// Converts gamepad-style position axes into VP-style nudge impulses. + /// + /// + /// Adapted from VP's vpinball/src/physics/cabinet/GamepadNudge.*. + /// Sticks and controller axes do not measure actual cabinet movement; they + /// express player intent, so this path detects an intent peak and then drives + /// the shared cabinet spring. + /// + public struct GamepadNudgeState + { + private const int DeactivationDelayMs = 10000; + + public NudgePhysicsSensorState XSensor; + public NudgePhysicsSensorState YSensor; + public NudgeIntentState Intent; + public CabinetPhysicsState Cabinet; + public float Strength; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + + private int _deactivationDelay; + + /// + /// Creates a gamepad intent sensor with optional X/Y mappings. + /// + public GamepadNudgeState(float strength, bool xMapped, bool yMapped) + { + XSensor = default; + YSensor = default; + XSensor.Configure(xMapped, SensorMappingKind.Position); + YSensor.Configure(yMapped, SensorMappingKind.Position); + Intent = new NudgeIntentState(true); + Cabinet = CabinetPhysicsState.Default; + Strength = math.clamp(strength, 0f, 2f); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + _deactivationDelay = 0; + } + + public bool IsActive => _deactivationDelay > 0; + + /// + /// Applies the latest mapped gamepad position sample. + /// + public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) + { + switch (channel) { + case NudgeSensorChannel.X: + XSensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.Y: + YSensor.SetValue(value, timestampUsec); + break; + } + } + + /// + /// Advances intent detection and cabinet spring response by one millisecond. + /// + public void StepOneMillisecond() + { + var x = XSensor.Value * Strength * 16f; + var y = YSensor.Value * Strength * 12f; + Intent.StepOneMillisecond(new float2(x, y)); + + if (Intent.IsImpulseInProgress) { + Cabinet.StepOneMillisecond(Cabinet.Mass * Intent.ImpulseAcceleration); + _deactivationDelay = DeactivationDelayMs; + } else { + Cabinet.StepOneMillisecond(float2.zero); + if (_deactivationDelay > 0) { + _deactivationDelay--; + } + } + + CabinetAcceleration = Cabinet.CabinetAcceleration; + CabinetOffset = Cabinet.CabinetOffset; + } + } + + /// + /// Interprets real cabinet motion sensors and produces cabinet acceleration + /// for physics. + /// + /// + /// Adapted from VP's + /// vpinball/src/physics/cabinet/CabinetNudgeSensor.* with supporting + /// filter/calibration ports from MotionKalmanAxis.h and + /// MotionGainCalibratorAxis.h. Direct mode uses measured motion; + /// cabinet-intent mode runs the same measured signal through the VP intent + /// detector and applies a synthesized cabinet impulse. + /// + public struct CabinetSensorState + { + private const int DeactivationDelayMs = 10000; + private const float GainConfidenceThreshold = 0.5f; + private const int CrossRestCountThreshold = 75; + private const int ForceRestCountThreshold = CrossRestCountThreshold + 200; + + /// + /// Per-channel state used to align native timestamps to the simulation + /// clock and detect rest. + /// + private struct SyncedSensor + { + public NudgePhysicsSensorState Sensor; + public ulong LastTimestampUsec; + public long ClockDeltaUsec; + public int RestCount; + public byte ForceRest; + public float LastValue; + + /// + /// Enables or disables the channel and clears synchronization state. + /// + public void Configure(bool mapped, SensorMappingKind kind) + { + Sensor.Configure(mapped, kind); + LastTimestampUsec = 0; + ClockDeltaUsec = 0; + RestCount = 0; + ForceRest = 0; + LastValue = 0f; + } + } + + public float Strength; + public float CabinetMassKg; + public MotionKalmanAxis KalmanX; + public MotionKalmanAxis KalmanY; + public MotionGainCalibratorAxis GainCalibratorX; + public MotionGainCalibratorAxis GainCalibratorY; + public CabinetPhysicsState Cabinet; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + public int DeactivationDelay => _deactivationDelay; + public bool IsIntentSensor => _intentEnabled != 0; + public bool IsActive => _deactivationDelay > 0; + public float2 KalmanAcceleration => new(KalmanX.Acceleration, KalmanY.Acceleration); + public float2 Gain => new(GainCalibratorX.Gain, GainCalibratorY.Gain); + public float2 GainConfidence => new(GainCalibratorX.GlobalConfidence, GainCalibratorY.GlobalConfidence); + + private SyncedSensor _xVelSensor; + private SyncedSensor _yVelSensor; + private SyncedSensor _xAccSensor; + private SyncedSensor _yAccSensor; + private EmaState _emaX; + private EmaState _emaY; + private ulong _timeUs; + private NudgeIntentState _intent; + private byte _intentEnabled; + private int _deactivationDelay; + + /// + /// Creates a cabinet sensor from mapped velocity/acceleration channels. + /// + public CabinetSensorState(NudgeSensorType type, float strength, float cabinetMassKg, + bool velXMapped, bool velYMapped, bool accXMapped, bool accYMapped) + { + Strength = math.clamp(strength, 0f, 2f); + CabinetMassKg = math.clamp(cabinetMassKg, 0f, 200f); + KalmanX = MotionKalmanAxis.Default; + KalmanY = MotionKalmanAxis.Default; + GainCalibratorX = MotionGainCalibratorAxis.Default; + GainCalibratorY = MotionGainCalibratorAxis.Default; + Cabinet = CabinetPhysicsState.Default; + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + _xVelSensor = default; + _yVelSensor = default; + _xAccSensor = default; + _yAccSensor = default; + _xVelSensor.Configure(velXMapped, SensorMappingKind.Velocity); + _yVelSensor.Configure(velYMapped, SensorMappingKind.Velocity); + _xAccSensor.Configure(accXMapped, SensorMappingKind.Acceleration); + _yAccSensor.Configure(accYMapped, SensorMappingKind.Acceleration); + _emaX = new EmaState(0.004f); + _emaY = new EmaState(0.004f); + _timeUs = 0; + _intent = new NudgeIntentState(false); + _intentEnabled = type == NudgeSensorType.CabinetIntent ? (byte)1 : (byte)0; + _deactivationDelay = 0; + } + + /// + /// Stores the latest mapped velocity or acceleration sample for the selected + /// channel. + /// + public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) + { + switch (channel) { + case NudgeSensorChannel.VelocityX: + _xVelSensor.Sensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.VelocityY: + _yVelSensor.Sensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.AccelerationX: + _xAccSensor.Sensor.SetValue(value, timestampUsec); + break; + case NudgeSensorChannel.AccelerationY: + _yAccSensor.Sensor.SetValue(value, timestampUsec); + break; + } + } + + /// + /// Advances sensor fusion, rest handling, and cabinet output by one + /// millisecond. + /// + public void StepOneMillisecond() + { + if (_deactivationDelay > 0) { + _deactivationDelay--; + } + + _timeUs += 1000; + UpdateAxis(ref _xVelSensor, ref _xAccSensor, ref KalmanX, ref GainCalibratorX); + UpdateAxis(ref _yVelSensor, ref _yAccSensor, ref KalmanY, ref GainCalibratorY); + + if (_intentEnabled != 0) { + _intent.StepOneMillisecond(new float2(KalmanX.Acceleration * (4f / 3f), KalmanY.Acceleration)); + if (_intent.IsImpulseInProgress) { + Cabinet.StepOneMillisecond(Cabinet.Mass * _intent.ImpulseAcceleration); + _deactivationDelay = DeactivationDelayMs; + } else { + Cabinet.StepOneMillisecond(float2.zero); + } + CabinetAcceleration = Cabinet.CabinetAcceleration; + } else { + // Note: the reference (CabinetNudgeSensor.cpp:280-285) multiplies by the + // strength scale twice, making direct-mode output scale with strength squared. + // That's an upstream bug we deliberately don't reproduce: strength is + // applied once, linearly. + CabinetAcceleration.x = _emaX.Update(KalmanX.Acceleration, 0.001f); + CabinetAcceleration.y = _emaY.Update(KalmanY.Acceleration, 0.001f); + CabinetAcceleration *= Strength * CabinetMassKg / Cabinet.Mass; + Cabinet.StepOneMillisecond(Cabinet.Mass * CabinetAcceleration); + } + CabinetOffset = Cabinet.CabinetOffset; + } + + /// + /// Updates one physical axis from its velocity and/or acceleration channels. + /// + /// + /// When both channels are mapped, acceleration is trusted until the gain + /// calibrator has enough confidence to scale velocity into acceleration + /// units. Rest constraints reset accumulated filter drift once both channels + /// have either crossed zero after a quiet period or remained quiet long + /// enough to be considered settled. + /// + private void UpdateAxis(ref SyncedSensor velSensor, ref SyncedSensor accSensor, ref MotionKalmanAxis kalmanFilter, + ref MotionGainCalibratorAxis gainCalibrator) + { + var isAccAndVelMapped = accSensor.Sensor.IsMapped && velSensor.Sensor.IsMapped; + if (isAccAndVelMapped) { + if (gainCalibrator.GlobalConfidence < GainConfidenceThreshold || gainCalibrator.Gain < 0.01f) { + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + velSensor.RestCount = accSensor.RestCount; + } else if (accSensor.Sensor.TimestampUsec < velSensor.Sensor.TimestampUsec) { + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + UpdateAxisSensor(ref velSensor, ref kalmanFilter, 1f / gainCalibrator.Gain); + } else { + UpdateAxisSensor(ref velSensor, ref kalmanFilter, 1f / gainCalibrator.Gain); + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + } + } else { + UpdateAxisSensor(ref accSensor, ref kalmanFilter, 1f); + UpdateAxisSensor(ref velSensor, ref kalmanFilter, 1f); + } + + accSensor.ForceRest = (byte)(accSensor.ForceRest != 0 + || (accSensor.RestCount > CrossRestCountThreshold && accSensor.LastValue * accSensor.Sensor.Value < 0f) ? 1 : 0); + velSensor.ForceRest = (byte)(velSensor.ForceRest != 0 + || (velSensor.RestCount > CrossRestCountThreshold && velSensor.LastValue * velSensor.Sensor.Value < 0f) ? 1 : 0); + + var isRest = (accSensor.ForceRest != 0 || accSensor.RestCount > ForceRestCountThreshold) + && (velSensor.ForceRest != 0 || velSensor.RestCount > ForceRestCountThreshold); + if (isRest) { + velSensor.ForceRest = 1; + accSensor.ForceRest = 1; + kalmanFilter.UpdateRestConstraints(_timeUs); + } + + accSensor.LastValue = accSensor.Sensor.Value; + velSensor.LastValue = velSensor.Sensor.Value; + + if (isAccAndVelMapped) { + if (!isRest) { + if (!gainCalibrator.IsSegmentActive) { + gainCalibrator.StartSegment(_timeUs); + } + gainCalibrator.AddSample(_timeUs, velSensor.Sensor.Value, accSensor.Sensor.Value); + } else if (gainCalibrator.IsSegmentActive) { + gainCalibrator.EndSegment(); + } + } + + kalmanFilter.PredictTo(_timeUs); + } + + /// + /// Applies one mapped channel to the Kalman filter after aligning its native + /// timestamp to simulation time. + /// + /// + /// Native input timestamps can be ahead of simulation time because sampling + /// happens on a different thread. The clock delta is adjusted instead of + /// feeding future samples into the filter. + /// + private void UpdateAxisSensor(ref SyncedSensor sensor, ref MotionKalmanAxis axis, float axisGain) + { + if (!sensor.Sensor.IsMapped) { + sensor.RestCount = DeactivationDelayMs; + return; + } + + var restThreshold = sensor.Sensor.Kind == SensorMappingKind.Acceleration ? 0.020f : 0.002f; + if (math.abs(sensor.Sensor.Value) < restThreshold) { + sensor.RestCount++; + if (sensor.ForceRest != 0) { + return; + } + } else { + sensor.RestCount = 0; + sensor.ForceRest = 0; + _deactivationDelay = DeactivationDelayMs; + } + + var timestampUsec = sensor.Sensor.TimestampUsec; + if (timestampUsec == 0 || timestampUsec == sensor.LastTimestampUsec) { + return; + } + + sensor.LastTimestampUsec = timestampUsec; + var alignedTimestampUsec = (long)timestampUsec + sensor.ClockDeltaUsec; + if (alignedTimestampUsec > (long)_timeUs) { + sensor.ClockDeltaUsec = (long)_timeUs - (long)timestampUsec; + alignedTimestampUsec = (long)_timeUs; + } + if (alignedTimestampUsec < 0) { + alignedTimestampUsec = 0; + } + + if (sensor.Sensor.Kind == SensorMappingKind.Velocity) { + axis.UpdateVelocity((ulong)alignedTimestampUsec, axisGain * sensor.Sensor.Value); + } else if (sensor.Sensor.Kind == SensorMappingKind.Acceleration) { + axis.UpdateAcceleration((ulong)alignedTimestampUsec, axisGain * sensor.Sensor.Value); + } + } + + /// + /// Small one-pole smoother used to remove the last bit of direct-mode sensor + /// chatter before feeding cabinet acceleration to physics. + /// + private struct EmaState + { + private float _tau; + private float _value; + private byte _initialized; + + /// + /// Creates an exponential moving average with the supplied time constant. + /// + public EmaState(float tau) + { + _tau = math.max(1.0e-6f, tau); + _value = 0f; + _initialized = 0; + } + + /// + /// Filters one sample and returns the smoothed value. + /// + public float Update(float sample, float dt) + { + if (_initialized == 0) { + _value = sample; + _initialized = 1; + return _value; + } + + var alpha = 1f - math.exp(-dt / _tau); + _value += alpha * (sample - _value); + return _value; + } + } + } + + /// + /// Runtime wrapper for one configured nudge sensor slot. + /// + /// + /// Applies the sensor mount transform before dispatching samples to either the + /// gamepad-intent or cabinet-sensor implementation. Keeping this transform at + /// the slot boundary ensures the graph, calibration, and physics paths all see + /// cabinet-space axes. + /// + public struct NudgeSensorState + { + public NudgeSensorType Type; + public byte Enabled; + public NudgeSensorMountRotation MountRotation; + public byte MountMirror; + public GamepadNudgeState Gamepad; + public CabinetSensorState Cabinet; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + public ulong LastActivityTimestampUsec; + + public bool IsEnabled => Enabled != 0; + public bool IsActive => IsEnabled && (Type == NudgeSensorType.GamepadIntent ? Gamepad.IsActive : Cabinet.IsActive); + + /// + /// Creates an enabled sensor slot from runtime configuration. + /// + public NudgeSensorState(NudgeSensorRuntimeConfig config) + { + Type = config.Type; + Enabled = 1; + MountRotation = NudgeSensorMountTransform.NormalizeRotation(config.MountRotation); + MountMirror = config.MountMirror; + var xMapped = config.XMapped != 0; + var yMapped = config.YMapped != 0; + var velXMapped = config.VelocityXMapped != 0; + var velYMapped = config.VelocityYMapped != 0; + var accXMapped = config.AccelerationXMapped != 0; + var accYMapped = config.AccelerationYMapped != 0; + NudgeSensorMountTransform.TransformMappedAxes(ref xMapped, ref yMapped, MountRotation); + NudgeSensorMountTransform.TransformMappedAxes(ref velXMapped, ref velYMapped, MountRotation); + NudgeSensorMountTransform.TransformMappedAxes(ref accXMapped, ref accYMapped, MountRotation); + Gamepad = new GamepadNudgeState(config.Strength, xMapped, yMapped); + Cabinet = new CabinetSensorState(config.Type, config.Strength, config.CabinetMassKg, + velXMapped, velYMapped, + accXMapped, accYMapped); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + LastActivityTimestampUsec = 0; + } + + /// + /// Disables this sensor and clears any last produced cabinet motion. + /// + public void Disable() + { + Enabled = 0; + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + LastActivityTimestampUsec = 0; + } + + /// + /// Applies mount orientation and forwards a mapped sample to the active + /// sensor implementation. + /// + public void ApplySample(NudgeSensorChannel channel, float value, ulong timestampUsec) + { + if (!IsEnabled) { + return; + } + NudgeSensorMountTransform.TransformChannel(ref channel, ref value, MountRotation, MountMirror != 0); + if (math.abs(value) > 1.0e-6f && timestampUsec > LastActivityTimestampUsec) { + LastActivityTimestampUsec = timestampUsec; + } + if (Type == NudgeSensorType.GamepadIntent) { + Gamepad.ApplySample(channel, value, timestampUsec); + } else { + Cabinet.ApplySample(channel, value, timestampUsec); + } + } + + /// + /// Advances the active sensor model and publishes cabinet acceleration and + /// visual offset for the current physics tick. + /// + public void StepOneMillisecond() + { + if (!IsEnabled) { + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + return; + } + + if (Type == NudgeSensorType.GamepadIntent) { + Gamepad.StepOneMillisecond(); + CabinetAcceleration = Gamepad.CabinetAcceleration; + CabinetOffset = Gamepad.CabinetOffset; + } else { + Cabinet.StepOneMillisecond(); + CabinetAcceleration = Cabinet.CabinetAcceleration; + CabinetOffset = Cabinet.CabinetOffset; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs.meta new file mode 100644 index 000000000..747eae074 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f2833467f332487f8aabebeeb8e770fe +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs new file mode 100644 index 000000000..ad0399e5b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs @@ -0,0 +1,39 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + /// + /// Defines how a configured analog nudge sensor should be interpreted. + /// + public enum NudgeSensorType + { + /// + /// Treats X/Y values as player intent, useful for sticks or controllers. + /// + GamepadIntent = 0, + + /// + /// Treats cabinet acceleration as intent and synthesizes a cabinet impulse. + /// + CabinetIntent = 1, + + /// + /// Treats cabinet velocity/acceleration as measured physical cabinet motion. + /// + CabinetDirect = 2 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs.meta new file mode 100644 index 000000000..daaee2a24 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeSensorType.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dc101d22416a4391b59fcfde46ad5b65 +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs new file mode 100644 index 000000000..c9f2b418b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs @@ -0,0 +1,305 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Command queued by the main thread to apply one keyboard-driven cabinet + /// impulse on the simulation thread. + /// + internal readonly struct KeyboardNudgeCommand + { + public readonly float AngleDeg; + public readonly float Force; + + /// + /// Creates a keyboard nudge command in playfield degrees. + /// + public KeyboardNudgeCommand(float angleDeg, float force) + { + AngleDeg = angleDeg; + Force = force; + } + } + + /// + /// Command queued by the input thread to deliver one mapped analog nudge + /// sample to the simulation thread. + /// + internal readonly struct NudgeSensorSampleCommand + { + public readonly int SensorIndex; + public readonly NudgeSensorChannel Channel; + public readonly float Value; + public readonly ulong TimestampUsec; + + /// + /// Creates a timestamped sensor sample for the configured sensor slot. + /// + public NudgeSensorSampleCommand(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) + { + SensorIndex = sensorIndex; + Channel = channel; + Value = value; + TimestampUsec = timestampUsec; + } + } + + /// + /// Owns all cabinet nudge sources and exposes the single cabinet motion value + /// consumed by the physics step. + /// + /// + /// This is the VPE-side coordinator for the VP cabinet nudge model, tying + /// together ports/adaptations from + /// vpinball/src/physics/cabinet/NudgeHandler.*, + /// KeyboardNudge.*, GamepadNudge.*, and + /// CabinetNudgeSensor.*. Keyboard input intentionally wins while its + /// spring response is active; otherwise the most recently active analog sensor + /// supplies the cabinet acceleration and visual offset. + /// + public struct NudgeState + { + public const int MaxSensors = 4; + + public KeyboardNudgeState Keyboard; + public NudgeSensorState Sensor0; + public NudgeSensorState Sensor1; + public NudgeSensorState Sensor2; + public NudgeSensorState Sensor3; + public int SensorCount; + public int ActiveSourceIndex; + public float2 CabinetAcceleration; + public float2 CabinetOffset; + public float2 MaxCabinetAcceleration; + public int KeyboardNudgeIndex; + + /// + /// Creates a nudge coordinator with keyboard nudging enabled and no analog + /// sensors configured. + /// + public NudgeState(KeyboardNudgeMode keyboardMode, float keyboardStrength, float nudgeTime, + float keyboardCabinetDamping = CabinetPhysicsState.DefaultKeyboardDampingRatio) + { + Keyboard = new KeyboardNudgeState(keyboardMode, keyboardStrength, nudgeTime, keyboardCabinetDamping); + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + MaxCabinetAcceleration = float2.zero; + KeyboardNudgeIndex = 0; + Sensor0 = default; + Sensor1 = default; + Sensor2 = default; + Sensor3 = default; + SensorCount = 0; + ActiveSourceIndex = -2; + } + + /// + /// Applies a manual nudge impulse and increments the keyboard nudge counter + /// used by telemetry/UI. + /// + public void ApplyKeyboardImpulse(float angleDeg, float force) + { + KeyboardNudgeIndex++; + Keyboard.Nudge(angleDeg, force); + } + + /// + /// Sets the number of analog sensor slots that should be considered active. + /// + public void ConfigureSensors(int count) + { + SensorCount = math.clamp(count, 0, MaxSensors); + for (var i = SensorCount; i < MaxSensors; i++) { + DisableSensor(i); + } + } + + /// + /// Rebuilds one analog sensor slot from serialized/player configuration. + /// + public void ConfigureSensor(int index, NudgeSensorRuntimeConfig config) + { + if ((uint)index >= MaxSensors) { + return; + } + config.Strength = math.clamp(config.Strength, 0f, 2f); + config.CabinetMassKg = math.clamp(config.CabinetMassKg <= 0f ? 113f : config.CabinetMassKg, 0f, 200f); + config.MountRotation = NudgeSensorMountTransform.NormalizeRotation(config.MountRotation); + SetSensor(index, new NudgeSensorState(config)); + if (index >= SensorCount) { + SensorCount = index + 1; + } + } + + /// + /// Delivers one mapped native-input value to the selected sensor slot. + /// + public void ApplySensorSample(int sensorIndex, NudgeSensorChannel channel, float value, ulong timestampUsec) + { + if ((uint)sensorIndex >= SensorCount) { + return; + } + var sensor = GetSensor(sensorIndex); + sensor.ApplySample(channel, value, timestampUsec); + SetSensor(sensorIndex, sensor); + } + + /// + /// Advances keyboard and sensor models one millisecond and selects the + /// cabinet motion source for this physics tick. + /// + public void StepOneMillisecond() + { + Keyboard.StepOneMillisecond(); + StepSensor(0); + StepSensor(1); + StepSensor(2); + StepSensor(3); + + if (Keyboard.IsActive) { + CabinetAcceleration = Keyboard.CabinetAcceleration; + CabinetOffset = Keyboard.CabinetOffset; + ActiveSourceIndex = -1; + } else if (TryGetActiveSensor(out var activeSensorIndex, out var activeSensor)) { + CabinetAcceleration = activeSensor.CabinetAcceleration; + CabinetOffset = activeSensor.CabinetOffset; + ActiveSourceIndex = activeSensorIndex; + } else { + CabinetAcceleration = float2.zero; + CabinetOffset = float2.zero; + ActiveSourceIndex = -2; + } + + if (math.abs(CabinetAcceleration.x) > math.abs(MaxCabinetAcceleration.x)) { + MaxCabinetAcceleration.x = CabinetAcceleration.x; + } + if (math.abs(CabinetAcceleration.y) > math.abs(MaxCabinetAcceleration.y)) { + MaxCabinetAcceleration.y = CabinetAcceleration.y; + } + } + + /// + /// Returns the largest signed acceleration seen since the last read and + /// clears the telemetry accumulator. + /// + public float2 ReadAndResetMaxCabinetAcceleration() + { + var value = MaxCabinetAcceleration; + MaxCabinetAcceleration = float2.zero; + return value; + } + + /// + /// Advances a configured sensor slot and writes the value back into the + /// fixed field storage. + /// + private void StepSensor(int index) + { + if (index >= SensorCount) { + return; + } + var sensor = GetSensor(index); + sensor.StepOneMillisecond(); + SetSensor(index, sensor); + } + + /// + /// Finds the analog sensor that should drive cabinet motion this tick. + /// + /// + /// Later timestamps win so that a device the user is actively touching + /// supersedes an older device that is still ringing down. Equal timestamps + /// fall back to the stronger cabinet motion. + /// + private bool TryGetActiveSensor(out int index, out NudgeSensorState sensor) + { + var bestScore = -1f; + var bestActivityTimestampUsec = 0UL; + index = -2; + sensor = default; + + for (var i = 0; i < SensorCount; i++) { + var candidate = GetSensor(i); + if (!candidate.IsActive) { + continue; + } + var score = SensorActivityScore(candidate); + if (index >= 0) { + if (candidate.LastActivityTimestampUsec < bestActivityTimestampUsec) { + continue; + } + if (candidate.LastActivityTimestampUsec == bestActivityTimestampUsec && score <= bestScore) { + continue; + } + } + bestScore = score; + bestActivityTimestampUsec = candidate.LastActivityTimestampUsec; + index = i; + sensor = candidate; + } + return index >= 0; + } + + /// + /// Scores active sensors by physical output rather than raw input, so intent + /// and direct sensors can be compared fairly. + /// + private static float SensorActivityScore(NudgeSensorState sensor) + { + var accelerationScore = math.lengthsq(sensor.CabinetAcceleration); + return accelerationScore > 1.0e-9f ? accelerationScore : math.lengthsq(sensor.CabinetOffset); + } + + private void DisableSensor(int index) + { + var sensor = GetSensor(index); + sensor.Disable(); + SetSensor(index, sensor); + } + + private NudgeSensorState GetSensor(int index) + { + return index switch { + 0 => Sensor0, + 1 => Sensor1, + 2 => Sensor2, + 3 => Sensor3, + _ => default + }; + } + + private void SetSensor(int index, NudgeSensorState sensor) + { + switch (index) { + case 0: + Sensor0 = sensor; + break; + case 1: + Sensor1 = sensor; + break; + case 2: + Sensor2 = sensor; + break; + case 3: + Sensor3 = sensor; + break; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs.meta new file mode 100644 index 000000000..a7edd1296 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/NudgeState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 593f37a235c3419a899ef0b4645ede9c +timeCreated: 1783190004 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs new file mode 100644 index 000000000..99534bc43 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs @@ -0,0 +1,221 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + /// + /// Simulates the mechanical tilt plumb bob and queues tilt switch edges. + /// + /// + /// This is a C# port/adaptation of + /// vpinball/src/physics/cabinet/PlumbHandler.*. VP moved this out of + /// VBS nudging plugins so it can run at the stable one millisecond physics + /// cadence; VPE follows that model and feeds the result back to gamelogic as + /// switch transitions. + /// + public struct PlumbState + { + private const float Gravity = 9.80665f; + private const float DefaultPoleLength = 0.10f; + private const float DampingCoef0 = 1.25f; + private const float DampingCoef1 = 0.75f; + + public float PoleLength; + public float TiltThresholdRad; + public float3 Position; + public float3 AngularVelocity; + public int TiltIndex; + public float MaxTiltPercent; + public float2 MaxPlumbPosition; + public FixedList32Bytes PendingTiltStates; + + private byte _enabled; + private byte _tiltHigh; + private float _angularDamping0; + private float _angularDamping1; + private float _cabinetAccelerationScale; + + public bool Enabled + { + get => _enabled != 0; + set => _enabled = value ? (byte)1 : (byte)0; + } + + public bool TiltHigh + { + get => _tiltHigh != 0; + private set => _tiltHigh = value ? (byte)1 : (byte)0; + } + + /// + /// Creates a plumb bob at rest below the pivot. + /// + public PlumbState(bool enabled, float damping, float tiltThresholdDeg) + { + PoleLength = DefaultPoleLength; + TiltThresholdRad = math.radians(math.clamp(tiltThresholdDeg, 0.5f, 4f)); + Position = new float3(0f, 0f, -DefaultPoleLength); + AngularVelocity = float3.zero; + TiltIndex = 0; + MaxTiltPercent = 0f; + MaxPlumbPosition = float2.zero; + PendingTiltStates = default; + _enabled = enabled ? (byte)1 : (byte)0; + _tiltHigh = 0; + _angularDamping0 = DampingCoef0 * math.clamp(damping, 0f, 2f); + _angularDamping1 = DampingCoef1 * math.clamp(damping, 0f, 2f); + _cabinetAccelerationScale = 1f; + } + + /// + /// Updates runtime plumb settings without resetting bob position. + /// + public void Configure(bool enabled, float damping, float tiltThresholdDeg) + { + // Runtime reconfiguration must not teleport the pendulum or lose the + // script-visible state (the reference setters preserve it too). + _enabled = enabled ? (byte)1 : (byte)0; + TiltThresholdRad = math.radians(math.clamp(tiltThresholdDeg, 0.5f, 4f)); + _angularDamping0 = DampingCoef0 * math.clamp(damping, 0f, 2f); + _angularDamping1 = DampingCoef1 * math.clamp(damping, 0f, 2f); + + // If the tilt switch is currently closed and the plumb no longer steps + // (disabled) or the threshold moved past the bob, emit a release edge so + // the consumer side doesn't stay stuck on "tilted". + if (TiltHigh && !enabled) { + TiltHigh = false; + if (PendingTiltStates.Length < PendingTiltStates.Capacity) { + PendingTiltStates.Add(0); + } + } + } + + /// + /// Integrates the plumb bob one millisecond in the accelerating cabinet + /// reference frame. + /// + public void StepOneMillisecond(float2 cabinetAcceleration) + { + if (!Enabled || TiltThresholdRad <= 0f) { + return; + } + + const float dt = 0.001f; + var poleAxis = Position / PoleLength; + var plumbAcceleration = new float3( + -cabinetAcceleration.x * _cabinetAccelerationScale, + -cabinetAcceleration.y * _cabinetAccelerationScale, + -Gravity + ); + + var torque = math.cross(Position, plumbAcceleration); + var alpha = torque / (PoleLength * PoleLength); + var damping = _angularDamping0 + _angularDamping1 * math.length(AngularVelocity); + alpha -= AngularVelocity * damping; + + AngularVelocity += alpha * dt; + AngularVelocity -= poleAxis * math.dot(AngularVelocity, poleAxis); + + Position += math.cross(AngularVelocity, Position) * dt; + var positionLength = math.length(Position); + if (positionLength > 1.0e-8f) { + Position *= PoleLength / positionLength; + } else { + Position = new float3(0f, 0f, -PoleLength); + } + + poleAxis = Position / PoleLength; + AngularVelocity -= poleAxis * math.dot(AngularVelocity, poleAxis); + + var psi = math.atan2(math.sqrt(Position.x * Position.x + Position.y * Position.y), -Position.z); + var tiltPercent = 100f * psi / TiltThresholdRad; + var tilted = false; + if (tiltPercent > 100f) { + tilted = true; + ClampToTiltRingAndBounce(TiltThresholdRad); + } + + if (TiltHigh != tilted) { + TiltHigh = tilted; + if (tilted) { + TiltIndex++; + } + if (PendingTiltStates.Length < PendingTiltStates.Capacity) { + PendingTiltStates.Add(tilted ? (byte)1 : (byte)0); + } + } + + if (tiltPercent > MaxTiltPercent) { + MaxTiltPercent = tiltPercent; + } + if (math.abs(Position.x) > math.abs(MaxPlumbPosition.x)) { + MaxPlumbPosition.x = Position.x; + } + if (math.abs(Position.y) > math.abs(MaxPlumbPosition.y)) { + MaxPlumbPosition.y = Position.y; + } + } + + /// + /// Returns the maximum plumb displacement and tilt percentage since the + /// last read, then clears those maxima. + /// + public float3 ReadAndResetTiltStatus() + { + var value = new float3(MaxPlumbPosition.x, MaxPlumbPosition.y, MaxTiltPercent); + MaxPlumbPosition = float2.zero; + MaxTiltPercent = 0f; + return value; + } + + /// + /// Drops pending tilt switch edges after the simulation thread has consumed + /// them. + /// + public void ClearPendingTiltEvents() + { + PendingTiltStates.Length = 0; + } + + /// + /// Keeps the bob on the tilt ring and reflects its velocity after hitting + /// the ring. + /// + /// + /// The small angular margin prevents the next integration step from starting + /// beyond the threshold and immediately re-colliding. + /// + private void ClampToTiltRingAndBounce(float tiltAngle) + { + var limitAngle = tiltAngle - 1e-3f; + Position.z = -PoleLength * math.cos(limitAngle); + var xy = PoleLength * math.sin(limitAngle); + var theta = math.atan2(Position.x, Position.y); + var axis = new float3(math.sin(theta), math.cos(theta), 0f); + Position.x = xy * axis.x; + Position.y = xy * axis.y; + + var poleAxis = Position / PoleLength; + var velocity = math.cross(AngularVelocity, Position); + var reflectedVelocity = velocity - 2f * math.dot(velocity, poleAxis) * poleAxis; + AngularVelocity = math.cross(Position, reflectedVelocity) / (PoleLength * PoleLength); + AngularVelocity *= 0.8f; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs.meta new file mode 100644 index 000000000..f43e176e6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Cabinet/PlumbState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b17d6c587f6e4d4e9174c5a62f2b2a83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs index e07e67977..04800b8fe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/GamelogicInputDispatchers.cs @@ -83,11 +83,13 @@ private readonly struct QueuedSwitchEvent { public readonly string SwitchId; public readonly bool IsClosed; + public readonly Action AfterDispatch; - public QueuedSwitchEvent(string switchId, bool isClosed) + public QueuedSwitchEvent(string switchId, bool isClosed, Action afterDispatch = null) { SwitchId = switchId; IsClosed = isClosed; + AfterDispatch = afterDispatch; } } @@ -105,13 +107,23 @@ public MainThreadQueuedInputDispatcher(IGamelogicEngine gamelogicEngine) } public void DispatchSwitch(string switchId, bool isClosed) + { + DispatchSwitch(switchId, isClosed, null); + } + + /// + /// Queues a switch event and runs on the main thread + /// right after the gamelogic engine processed it — i.e. after any synchronous reaction + /// (such as a scripted nudge) has taken place. + /// + public void DispatchSwitch(string switchId, bool isClosed, Action afterDispatch) { lock (_queueLock) { if (_queue.Count >= MaxQueuedEvents) { _droppedEvents++; return; } - _queue.Enqueue(new QueuedSwitchEvent(switchId, isClosed)); + _queue.Enqueue(new QueuedSwitchEvent(switchId, isClosed, afterDispatch)); } } @@ -139,6 +151,7 @@ public void FlushMainThread() } _gamelogicEngine.Switch(item.SwitchId, item.IsClosed); + item.AfterDispatch?.Invoke(); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index 4f774b634..ca9b55e24 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -8,20 +8,40 @@ using System.Runtime.InteropServices; using AOT; -namespace VisualPinball.Unity.Simulation -{ - /// - /// P/Invoke wrapper for native input polling library - /// - public static class NativeInputApi - { +namespace VisualPinball.Unity.Simulation +{ + /// + /// P/Invoke wrapper for the VisualPinball.NativeInput polling library. + /// + /// + /// The enum values, packing, string buffer sizes, and protocol version must + /// stay in lockstep with the native package. Managed code checks + /// at startup so stale native plugins fail fast + /// instead of silently misreading axis data. + /// + public static class NativeInputApi + { private const string DllName = "VisualPinball.NativeInput"; + + /// + /// Managed/native ABI version expected by this assembly. + /// + public const int ProtocolVersion = 2; + + /// Maximum UTF-8 bytes in a stable native device id. + public const int DeviceIdSize = 260; + + /// Maximum UTF-8 bytes in a display device name. + public const int DeviceNameSize = 128; + + /// Maximum UTF-8 bytes in an axis display name. + public const int AxisNameSize = 32; + + #region Enums - #region Enums - - /// - /// Input action enum (must match native enum) - /// + /// + /// Input action enum. Numeric values must match the native enum. + /// public enum InputAction { LeftFlipper = 0, @@ -60,19 +80,40 @@ public enum InputAction Service8 = 33, } - /// - /// Input binding type - /// - public enum BindingType - { - Keyboard = 0, - Gamepad = 1, - Mouse = 2, - } + /// + /// Native input binding type. Numeric values must match the native enum. + /// + public enum BindingType + { + Keyboard = 0, + Gamepad = 1, + Mouse = 2, + } + + /// + /// Native event payload type. Numeric values must match the native enum. + /// + public enum InputEventType + { + Action = 0, + Axis = 1, + DevicesChanged = 2, + } + + /// + /// Physical meaning reported for an analog axis by native input. + /// + public enum AxisKind + { + Position = 0, + Velocity = 1, + Acceleration = 2, + } - /// - /// Key codes (Windows virtual key codes) - /// + /// + /// Key codes used by native keyboard bindings. Values are Windows virtual + /// key codes. + /// public enum KeyCode { LShift = 0xA0, @@ -120,6 +161,7 @@ public enum KeyCode P = 0x50, T = 0x54, Y = 0x59, + Z = 0x5A, Numpad1 = 0x61, A = 0x41, @@ -129,6 +171,7 @@ public enum KeyCode W = 0x57, Minus = 0xBD, // VK_OEM_MINUS + Slash = 0xBF, // VK_OEM_2 Quote = 0xDE, // VK_OEM_7 Oem3 = 0xC0, // VK_OEM_3 (layout dependent) } @@ -137,64 +180,167 @@ public enum KeyCode #region Structures - /// - /// Input event structure (matches native struct layout) - /// - [StructLayout(LayoutKind.Sequential, Pack = 4)] - public struct InputEvent - { - public long TimestampUsec; - public int Action; // InputAction - public float Value; - private int _padding; - } + /// + /// Input event structure. Layout must match the native struct exactly. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputEvent + { + public long TimestampUsec; + public int EventType; // InputEventType + public int Action; // InputAction + public int DeviceIndex; + public int AxisId; + public float Value; + private int _padding; + } - /// - /// Input binding structure - /// - [StructLayout(LayoutKind.Sequential, Pack = 4)] - public struct InputBinding + /// + /// Input binding structure. Layout must match the native struct exactly. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputBinding { public int Action; // InputAction public int BindingType; // BindingType public int KeyCode; // KeyCode or button index - private int _padding; + private int _padding; + } + + /// + /// Native device metadata returned by enumeration. + /// + /// + /// The native side fills the string fields with UTF-8; decode them manually, + /// since ByValTStr would use the system ANSI codepage and garble + /// non-ASCII device names. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputDeviceInfo + { + public int DeviceIndex; + public int AxisCount; + public int IsConnected; + private int _padding; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = DeviceIdSize)] + private byte[] _stableId; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = DeviceNameSize)] + private byte[] _displayName; + + public string StableId => DecodeUtf8(_stableId); + public string DisplayName => DecodeUtf8(_displayName); + } + + /// + /// Native axis metadata and latest raw value for one device axis. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct InputAxisInfo + { + public int AxisId; + public int UsagePage; + public int Usage; + public int Kind; + public float RawValue; + private int _padding; + public long TimestampUsec; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = AxisNameSize)] + private byte[] _name; + + public string Name => DecodeUtf8(_name); } - #endregion + /// + /// Decodes a native null-terminated UTF-8 byte buffer. + /// + private static string DecodeUtf8(byte[] bytes) + { + if (bytes == null) { + return string.Empty; + } + var length = Array.IndexOf(bytes, (byte)0); + if (length < 0) { + length = bytes.Length; + } + return length == 0 ? string.Empty : System.Text.Encoding.UTF8.GetString(bytes, 0, length); + } + + #endregion #region Delegates - /// - /// Callback for input events - /// - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void InputEventCallback(ref InputEvent evt, IntPtr userData); + /// + /// Callback for input events. Invoked on the native input polling thread. + /// + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void InputEventCallback(ref InputEvent evt, IntPtr userData); #endregion #region Native Functions - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern int VpeInputInit(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeInputShutdown(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeInputSetBindings(InputBinding[] bindings, int count); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern int VpeInputStartPolling(InputEventCallback callback, IntPtr userData, int pollIntervalUs); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeInputStopPolling(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern long VpeGetTimestampUsec(); - - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - public static extern void VpeSetThreadPriority(); + /// + /// Initializes the native input subsystem. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputInit(); + + /// + /// Returns the native ABI protocol version. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputGetProtocolVersion(); + + /// + /// Shuts down native input and releases native resources. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputShutdown(); + + /// + /// Replaces the native action binding table. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputSetBindings(InputBinding[] bindings, int count); + + /// + /// Starts native polling and dispatches events through the supplied callback. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputStartPolling(InputEventCallback callback, IntPtr userData, int pollIntervalUs); + + /// + /// Stops native polling. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeInputStopPolling(); + + /// + /// Enumerates connected native input devices. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputListDevices([Out] InputDeviceInfo[] devices, int maxDevices); + + /// + /// Enumerates axes for one native input device. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int VpeInputListDeviceAxes(int deviceIndex, [Out] InputAxisInfo[] axes, int maxAxes); + + /// + /// Returns the native high-resolution timestamp in microseconds. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern long VpeGetTimestampUsec(); + + /// + /// Lets the native plugin raise priority for the polling thread. + /// + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void VpeSetThreadPriority(); #endregion } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index 7262b3c07..62f5b8f03 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -14,11 +14,17 @@ namespace VisualPinball.Unity.Simulation { - /// - /// Manages native input polling and forwards events to the simulation thread. - /// Runs input polling on a separate thread at high frequency (500-1000 Hz). - /// - public class NativeInputManager : IDisposable + /// + /// Manages native input polling and forwards events to the simulation thread. + /// Runs input polling on a separate thread at high frequency (500-1000 Hz). + /// + /// + /// Axis events are also exposed to for analog + /// cabinet sensors. Device enumeration is intentionally cached by native index + /// so the polling callback can resolve stable device ids without doing + /// allocation-heavy enumeration work on the hot path. + /// + public class NativeInputManager : IDisposable { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const string LogPrefix = "[VPE]"; @@ -36,8 +42,14 @@ public class NativeInputManager : IDisposable private const double PerfSampleWindowSeconds = 0.25; private long _inputPerfWindowStartTicks = Stopwatch.GetTimestamp(); private int _inputEventsInWindow; - private float _actualEventRateHz; - + private float _actualEventRateHz; + + // Immutable snapshot mapping native device indices to stable device ids, swapped as a whole + // on the main thread so the input polling thread can read it lock-free. Rebuilt after + // StartPolling, because that's when the native side switches to its polling enumeration, + // which is the only index space consistent with axis events. + private volatile Dictionary _deviceIdsByIndex = new(); + // Input configuration private readonly List _bindings = new(); @@ -80,10 +92,31 @@ public static NativeInputManager TryGetExistingInstance() return Volatile.Read(ref _instance); } - public bool IsPolling => _polling; + /// + /// Whether the native polling thread is currently running. + /// + public bool IsPolling => _polling; + + /// + /// Requested native polling rate in hertz. + /// + public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; + + /// + /// Measured incoming event rate over a short rolling window. + /// + public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; + + /// + /// Raised on the input polling thread for raw analog axis events. + /// + public event Action AxisInputReceived; - public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; - public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; + /// + /// Raised after a device arrival/removal was detected and the device-id + /// cache has been refreshed. Invoked on the input polling thread. + /// + public event Action DevicesChanged; private NativeInputManager() { @@ -95,20 +128,34 @@ private NativeInputManager() #region Public API /// - /// Initialize native input system - /// - public bool Initialize() + /// Initializes the native input system and verifies managed/native protocol + /// compatibility. + /// + public bool Initialize() { if (_initialized) return true; - int result = NativeInputApi.VpeInputInit(); - if (result == 0) - { - Logger.Error($"{LogPrefix} [NativeInputManager] Failed to initialize native input system"); - return false; - } - - _initialized = true; + int result = NativeInputApi.VpeInputInit(); + if (result == 0) + { + Logger.Error($"{LogPrefix} [NativeInputManager] Failed to initialize native input system"); + return false; + } + + try { + var protocolVersion = NativeInputApi.VpeInputGetProtocolVersion(); + if (protocolVersion != NativeInputApi.ProtocolVersion) { + Logger.Error($"{LogPrefix} [NativeInputManager] Native input protocol mismatch: managed={NativeInputApi.ProtocolVersion}, native={protocolVersion}"); + NativeInputApi.VpeInputShutdown(); + return false; + } + } catch (EntryPointNotFoundException) { + Logger.Error($"{LogPrefix} [NativeInputManager] Native input plugin is too old for protocol {NativeInputApi.ProtocolVersion}"); + NativeInputApi.VpeInputShutdown(); + return false; + } + + _initialized = true; Logger.Info($"{LogPrefix} [NativeInputManager] Initialized"); // Setup default bindings @@ -118,17 +165,17 @@ public bool Initialize() } /// - /// Set the simulation thread to forward input events to - /// - public void SetSimulationThread(SimulationThread simulationThread) + /// Sets the simulation thread that receives action input events. + /// + public void SetSimulationThread(SimulationThread simulationThread) { _simulationThread = simulationThread; } /// - /// Add an input binding - /// - public void AddBinding(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) + /// Adds a keyboard action binding to the built-in binding list. + /// + public void AddBinding(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) { _bindings.Add(new NativeInputApi.InputBinding { @@ -139,9 +186,9 @@ public void AddBinding(NativeInputApi.InputAction action, NativeInputApi.KeyCode } /// - /// Clear all bindings - /// - public void ClearBindings() + /// Clears the built-in binding list. + /// + public void ClearBindings() { _bindings.Clear(); } @@ -162,17 +209,107 @@ public static List ConfiguredBindings /// Whether the app window currently has focus. The host sets this each frame from /// Application.isFocused; native input events are dropped while it is false. /// - public static bool AppFocused - { - get => _appFocused; - set => _appFocused = value; + public static bool AppFocused + { + get => _appFocused; + set => _appFocused = value; + } + + /// + /// Enumerates native input devices and refreshes the device-index cache. + /// + /// + /// Calling this has the side effect of swapping the immutable + /// index-to-stable-id snapshot used by the polling thread. + /// + public IReadOnlyList ListDevices() + { + var result = new List(); + if (!_initialized) { + return result; + } + + var count = NativeInputApi.VpeInputListDevices(null, 0); + if (count <= 0) { + RebuildDeviceIdCache(result); + return result; + } + + var devices = new NativeInputApi.InputDeviceInfo[count]; + var copied = NativeInputApi.VpeInputListDevices(devices, devices.Length); + for (var i = 0; i < global::System.Math.Min(count, copied); i++) { + var axes = ListDeviceAxes(devices[i].DeviceIndex); + result.Add(new NativeInputDeviceInfo( + devices[i].DeviceIndex, + devices[i].StableId ?? string.Empty, + devices[i].DisplayName ?? string.Empty, + devices[i].IsConnected != 0, + axes + )); + } + RebuildDeviceIdCache(result); + return result; } /// - /// Start input polling + /// Resolves a native device index (as carried by axis events) to the device's stable id. + /// Safe to call from the input polling thread. /// - /// Polling interval in microseconds (default 500) - public bool StartPolling(int pollIntervalUs = 500) + public bool TryGetDeviceId(int deviceIndex, out string deviceId) + { + return _deviceIdsByIndex.TryGetValue(deviceIndex, out deviceId); + } + + /// + /// Replaces the polling-thread device-id snapshot with data from the latest + /// enumeration. + /// + private void RebuildDeviceIdCache(IReadOnlyList devices) + { + var cache = new Dictionary(devices.Count); + for (var i = 0; i < devices.Count; i++) { + cache[devices[i].DeviceIndex] = devices[i].Id; + } + _deviceIdsByIndex = cache; + } + + /// + /// Enumerates native axes for one device. + /// + public IReadOnlyList ListDeviceAxes(int deviceIndex) + { + if (!_initialized) { + return Array.Empty(); + } + + var count = NativeInputApi.VpeInputListDeviceAxes(deviceIndex, null, 0); + if (count <= 0) { + return Array.Empty(); + } + + var axes = new NativeInputApi.InputAxisInfo[count]; + var copied = NativeInputApi.VpeInputListDeviceAxes(deviceIndex, axes, axes.Length); + var result = new NativeInputAxisInfo[global::System.Math.Min(count, copied)]; + for (var i = 0; i < result.Length; i++) { + result[i] = new NativeInputAxisInfo( + axes[i].AxisId, + axes[i].Name ?? string.Empty, + axes[i].UsagePage, + axes[i].Usage, + (NativeInputApi.AxisKind)axes[i].Kind, + axes[i].RawValue, + axes[i].TimestampUsec + ); + } + return result; + } + + /// + /// Starts native input polling and sends the current action bindings to the + /// native layer. + /// + /// Polling interval in microseconds (default 500) + public bool StartPolling(int pollIntervalUs = 500) { #if UNITY_EDITOR // Avoid extremely aggressive polling in the editor; it can delay/derail PinMAME stop/start. @@ -214,13 +351,17 @@ public bool StartPolling(int pollIntervalUs = 500) _pollIntervalUs = pollIntervalUs; Logger.Info($"{LogPrefix} [NativeInputManager] Started polling at {pollIntervalUs}us interval ({1000000 / pollIntervalUs} Hz)"); + // The native side switched to its polling device enumeration; refresh the + // index-to-device-id cache so axis events resolve against the right snapshot. + ListDevices(); + return true; } /// - /// Stop input polling - /// - public void StopPolling() + /// Stops native input polling. + /// + public void StopPolling() { if (!_polling) return; @@ -237,9 +378,9 @@ public void StopPolling() #region Private Methods /// - /// Setup default input bindings - /// - private void SetupDefaultBindings() + /// Configures the built-in default keyboard bindings. + /// + private void SetupDefaultBindings() { ClearBindings(); _bindings.AddRange(BuildDefaultBindings()); @@ -298,11 +439,11 @@ void Add(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) Add(NativeInputApi.InputAction.Service6, NativeInputApi.KeyCode.PageUp); Add(NativeInputApi.InputAction.Service7, NativeInputApi.KeyCode.PageDown); - // Nudging - Add(NativeInputApi.InputAction.LeftNudge, NativeInputApi.KeyCode.Y); - Add(NativeInputApi.InputAction.RightNudge, NativeInputApi.KeyCode.Minus); - Add(NativeInputApi.InputAction.CenterNudge, NativeInputApi.KeyCode.Space); - Add(NativeInputApi.InputAction.Tilt, NativeInputApi.KeyCode.T); + // Nudging + Add(NativeInputApi.InputAction.LeftNudge, NativeInputApi.KeyCode.Z); + Add(NativeInputApi.InputAction.RightNudge, NativeInputApi.KeyCode.Slash); + Add(NativeInputApi.InputAction.CenterNudge, NativeInputApi.KeyCode.Space); + Add(NativeInputApi.InputAction.Tilt, NativeInputApi.KeyCode.T); // Plunger Add(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Return); @@ -311,31 +452,64 @@ void Add(NativeInputApi.InputAction action, NativeInputApi.KeyCode keyCode) } /// - /// Input event callback from native layer (called on input polling thread) - /// - [MonoPInvokeCallback(typeof(NativeInputApi.InputEventCallback))] - private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userData) - { - if (Interlocked.Exchange(ref _loggedFirstEvent, 1) == 0) { - Logger.Info($"{LogPrefix} [NativeInputManager] First event: Action={evt.Action}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); - } - if (Logger.IsTraceEnabled) { - Logger.Trace($"{LogPrefix} [NativeInputManager] Received from native: Action={evt.Action}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); + /// Receives native input events on the input polling thread. + /// + [MonoPInvokeCallback(typeof(NativeInputApi.InputEventCallback))] + private static void OnInputEvent(ref NativeInputApi.InputEvent evt, IntPtr userData) + { + if (Interlocked.Exchange(ref _loggedFirstEvent, 1) == 0) { + Logger.Info($"{LogPrefix} [NativeInputManager] First event: Type={evt.EventType}, Action={evt.Action}, Device={evt.DeviceIndex}, Axis={evt.AxisId}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); + } + if (Logger.IsTraceEnabled) { + Logger.Trace($"{LogPrefix} [NativeInputManager] Received from native: Type={evt.EventType}, Action={evt.Action}, Device={evt.DeviceIndex}, Axis={evt.AxisId}, Value={evt.Value}, Timestamp={evt.TimestampUsec}"); + } + + // Device hotplug notifications must be handled regardless of window + // focus, or the device-id cache goes stale while unfocused. + if (evt.EventType == (int)NativeInputApi.InputEventType.DevicesChanged) { + Volatile.Read(ref _instance)?.OnNativeDevicesChanged(); + return; } // Drop input while the app window isn't focused, so background key presses don't reach the game. if (!_appFocused) { return; - } - - // Forward to simulation thread via ring buffer - var instance = Volatile.Read(ref _instance); - instance?.MarkInputEventActivity(); - instance?._simulationThread?.EnqueueInputEvent(evt); + } + + var instance = Volatile.Read(ref _instance); + instance?.MarkInputEventActivity(); + if (evt.EventType == (int)NativeInputApi.InputEventType.Axis) { + instance?.AxisInputReceived?.Invoke(evt); + return; + } + + // Forward action events to simulation thread via ring buffer. + instance?._simulationThread?.EnqueueInputEvent(evt); } - private void MarkInputEventActivity() - { + /// + /// Refreshes native device metadata after a hotplug notification. + /// + /// + /// Called on the input polling thread. Rebuilding via + /// is safe here because the native listing takes its own lock and the id + /// cache is swapped atomically. + /// + private void OnNativeDevicesChanged() + { + try { + ListDevices(); + DevicesChanged?.Invoke(); + } catch (Exception e) { + Logger.Error(e, $"{LogPrefix} [NativeInputManager] Failed to refresh devices after hotplug"); + } + } + + /// + /// Updates the rolling input event-rate measurement. + /// + private void MarkInputEventActivity() + { Interlocked.Increment(ref _inputEventsInWindow); var nowTicks = Stopwatch.GetTimestamp(); @@ -356,9 +530,13 @@ private void MarkInputEventActivity() #endregion - #region Dispose - - public void Dispose() + #region Dispose + + /// + /// Stops polling, shuts down native input, and releases the singleton + /// instance. + /// + public void Dispose() { StopPolling(); @@ -371,6 +549,58 @@ public void Dispose() _instance = null; } - #endregion - } -} + #endregion + } + + /// + /// Managed snapshot of one native input device and its axes. + /// + public sealed class NativeInputDeviceInfo + { + /// + /// Creates a native input device snapshot. + /// + public NativeInputDeviceInfo(int deviceIndex, string id, string name, bool isConnected, IReadOnlyList axes) + { + DeviceIndex = deviceIndex; + Id = id; + Name = name; + IsConnected = isConnected; + Axes = axes; + } + + public int DeviceIndex { get; } + public string Id { get; } + public string Name { get; } + public bool IsConnected { get; } + public IReadOnlyList Axes { get; } + } + + /// + /// Managed snapshot of one native input axis and its latest raw value. + /// + public readonly struct NativeInputAxisInfo + { + /// + /// Creates a native input axis snapshot. + /// + public NativeInputAxisInfo(int axisId, string name, int usagePage, int usage, NativeInputApi.AxisKind kind, float rawValue, long timestampUsec) + { + AxisId = axisId; + Name = name; + UsagePage = usagePage; + Usage = usage; + Kind = kind; + RawValue = rawValue; + TimestampUsec = timestampUsec; + } + + public int AxisId { get; } + public string Name { get; } + public int UsagePage { get; } + public int Usage { get; } + public NativeInputApi.AxisKind Kind { get; } + public float RawValue { get; } + public long TimestampUsec { get; } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index f184e0c34..0d4da20c4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -16,7 +16,7 @@ namespace VisualPinball.Unity.Simulation /// Shared simulation state between simulation thread and Unity main thread. /// Uses triple-buffering for truly lock-free reads: the sim thread always /// writes to its own buffer, publishes via atomic exchange, and the main - /// thread acquires the latest published buffer — neither thread ever + /// thread acquires the latest published buffer; neither thread ever /// touches the other's active buffer. /// public class SimulationState : IDisposable @@ -178,6 +178,39 @@ public struct Snapshot public int Float2AnimationSourceCount; public byte Float2AnimationsTruncated; + /// + /// Latest cabinet acceleration from keyboard or analog nudge sources. + /// + public float2 NudgeCabinetAcceleration; + + /// + /// Latest cabinet spring displacement used by visual nudge. + /// + public float2 NudgeCabinetOffset; + + /// + /// Simulated plumb-bob position for diagnostics. + /// + public float3 PlumbPosition; + + /// + /// Maximum plumb displacement as a percentage of the tilt ring. + /// + public float PlumbTiltPercent; + + /// + /// Number of tilt switch transitions produced by the plumb simulation. + /// + public int PlumbTiltIndex; + + /// + /// Whether the simulated tilt switch is currently high. + /// + public byte PlumbTiltHigh; + + /// + /// Allocates persistent native buffers used by this snapshot. + /// public void Allocate() { CoilStates = new NativeArray(MaxCoils, Allocator.Persistent); @@ -213,8 +246,17 @@ public void Allocate() Float2AnimationCount = 0; Float2AnimationSourceCount = 0; Float2AnimationsTruncated = 0; + NudgeCabinetAcceleration = float2.zero; + NudgeCabinetOffset = float2.zero; + PlumbPosition = float3.zero; + PlumbTiltPercent = 0f; + PlumbTiltIndex = 0; + PlumbTiltHigh = 0; } + /// + /// Disposes persistent native buffers owned by this snapshot. + /// public void Dispose() { if (CoilStates.IsCreated) CoilStates.Dispose(); @@ -243,7 +285,7 @@ public void Dispose() /// /// Index of the most recently published buffer. - /// Shared between threads — accessed only via Interlocked.Exchange. + /// Shared between threads; accessed only via Interlocked.Exchange. /// private int _readyIndex; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 353da6b89..0fd4bc8d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -53,6 +53,8 @@ public class SimulationThread : IDisposable private readonly Action _simulationCoilDispatcher; private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; + private readonly System.Random _nudgeRandom = new System.Random(); + private readonly List _pendingPlumbTiltEvents = new List(8); private Thread _thread; private volatile bool _running = false; @@ -255,6 +257,23 @@ public ref readonly SimulationState.Snapshot GetSharedState() /// Thread: Main thread only (used during initialization). public SimulationState SharedState => _sharedState; + /// + /// Dispatches plumb tilt state changes on the main thread through the + /// table's plumb tilt component. Set by + /// only when that component exists and is enabled by player settings. + /// + public Action MainThreadTiltDispatcher { get; set; } + + /// + /// When true, native cabinet Tilt input is routed to + /// instead of directly to any + /// input-action switch mapping. This is used for real cabinet plumb bobs. + /// + public bool DispatchPhysicalTiltInputToMainThread { get; set; } + + private readonly Queue _mainThreadTiltStates = new(); + private readonly object _mainThreadTiltLock = new(); + /// /// Flush any queued main-thread input dispatches. /// @@ -262,6 +281,24 @@ public ref readonly SimulationState.Snapshot GetSharedState() public void FlushMainThreadInputDispatch() { _inputDispatcher.FlushMainThread(); + + while (true) { + bool tiltState; + lock (_mainThreadTiltLock) { + if (_mainThreadTiltStates.Count == 0) { + break; + } + tiltState = _mainThreadTiltStates.Dequeue(); + } + MainThreadTiltDispatcher?.Invoke(tiltState); + } + } + + private void QueueMainThreadTiltState(bool tiltState) + { + lock (_mainThreadTiltLock) { + _mainThreadTiltStates.Enqueue(tiltState); + } } /// @@ -465,6 +502,10 @@ private void ProcessInputEvents() while (_inputBuffer.TryDequeue(out var evt)) { + if (evt.EventType != (int)NativeInputApi.InputEventType.Action) { + continue; + } + var actionIndex = evt.Action; if ((uint)actionIndex >= (uint)_actionStates.Length) { continue; @@ -480,9 +521,33 @@ private void ProcessInputEvents() InputLatencyTracker.RecordInputPolled((NativeInputApi.InputAction)actionIndex, isPressed, evt.TimestampUsec); + if (actionIndex == (int)NativeInputApi.InputAction.Tilt + && DispatchPhysicalTiltInputToMainThread + && MainThreadTiltDispatcher != null) { + QueueMainThreadTiltState(isPressed); + _inputEventsProcessed++; + continue; + } + // Only forward to GLE once it's ready (or at least has started) if (_gamelogicEngine != null && _gamelogicStarted) { - SendMappedSwitch(actionIndex, isPressed); + if (isPressed && IsNudgeAction(actionIndex)) { + // The default nudge is suppressed when the gamelogic reacted to the + // key by nudging itself. For main-thread-dispatched GLEs the switch + // is only queued here, so the decision must run after the main + // thread actually delivered it; hence the after-dispatch callback. + var nudgeIndexBeforeSwitchDispatch = _physicsEngine.KeyboardNudgeIndex; + var nudgeActionIndex = actionIndex; + SendMappedSwitch(actionIndex, isPressed, () => { + if (nudgeIndexBeforeSwitchDispatch == _physicsEngine.KeyboardNudgeIndex) { + ApplyDefaultKeyboardNudge(nudgeActionIndex); + } + }); + } else { + SendMappedSwitch(actionIndex, isPressed); + } + } else if (isPressed && IsNudgeAction(actionIndex)) { + ApplyDefaultKeyboardNudge(actionIndex); } _inputEventsProcessed++; } @@ -525,13 +590,16 @@ private void ProcessExternalSwitchEvents() } } - private void SendMappedSwitch(int actionIndex, bool isPressed) + private void SendMappedSwitch(int actionIndex, bool isPressed, Action afterDispatch = null) { if ((uint)actionIndex >= (uint)_actionToSwitchId.Length) { + afterDispatch?.Invoke(); return; } var switchId = _actionToSwitchId[actionIndex]; if (switchId == null) { + // No switch mapped: the GLE never sees the action, run the follow-up now. + afterDispatch?.Invoke(); return; } @@ -539,6 +607,7 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) if (_actionToggleOnPress[actionIndex]) { // VP-style coin door behavior toggles only on key-down. if (!isPressed) { + afterDispatch?.Invoke(); return; } isClosed = !_actionSwitchStates[actionIndex]; @@ -562,7 +631,12 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) Logger.Info($"{LogPrefix} [SimulationThread] Input RightFlipper -> Switch({switchId}, True)"); } } - _inputDispatcher.DispatchSwitch(switchId, isClosed); + if (afterDispatch != null && _inputDispatcher is MainThreadQueuedInputDispatcher queuedDispatcher) { + queuedDispatcher.DispatchSwitch(switchId, isClosed, afterDispatch); + } else { + _inputDispatcher.DispatchSwitch(switchId, isClosed); + afterDispatch?.Invoke(); + } } private static bool IsFlipperAction(int actionIndex) @@ -575,6 +649,37 @@ private static bool IsFlipperAction(int actionIndex) || actionIndex == (int)NativeInputApi.InputAction.UpperRightFlipper; } + private static bool IsNudgeAction(int actionIndex) + { + return actionIndex == (int)NativeInputApi.InputAction.LeftNudge + || actionIndex == (int)NativeInputApi.InputAction.RightNudge + || actionIndex == (int)NativeInputApi.InputAction.CenterNudge; + } + + /// + /// Applies the built-in keyboard nudge for tables whose gamelogic did not + /// handle the nudge key itself. + /// + /// + /// VP tables can script their own nudge response. The simulation thread + /// therefore applies this fallback only after switch dispatch has had a + /// chance to call into gamelogic and increment the physics engine's keyboard + /// nudge counter. + /// + private void ApplyDefaultKeyboardNudge(int actionIndex) + { + const float baseForce = 2f; + var angle = ((float)_nudgeRandom.NextDouble() - 0.5f) * 15f * baseForce; + var force = (0.6f + (float)_nudgeRandom.NextDouble() * 0.8f) * baseForce; + if (actionIndex == (int)NativeInputApi.InputAction.LeftNudge) { + _physicsEngine.Nudge(75f + angle, force); + } else if (actionIndex == (int)NativeInputApi.InputAction.RightNudge) { + _physicsEngine.Nudge(285f + angle, force); + } else if (actionIndex == (int)NativeInputApi.InputAction.CenterNudge) { + _physicsEngine.Nudge(angle, force); + } + } + private void SyncAllMappedSwitches() { for (var i = 0; i < _actionToSwitchId.Length; i++) @@ -749,6 +854,22 @@ private static bool TryMapInputActionHint(string inputActionHint, out NativeInpu action = NativeInputApi.InputAction.SlamTilt; return true; } + if (inputActionHint == InputConstants.ActionLeftNudge) { + action = NativeInputApi.InputAction.LeftNudge; + return true; + } + if (inputActionHint == InputConstants.ActionRightNudge) { + action = NativeInputApi.InputAction.RightNudge; + return true; + } + if (inputActionHint == InputConstants.ActionCenterNudge) { + action = NativeInputApi.InputAction.CenterNudge; + return true; + } + if (inputActionHint == InputConstants.ActionTilt) { + action = NativeInputApi.InputAction.Tilt; + return true; + } action = default; return false; @@ -766,6 +887,33 @@ private void UpdatePhysics() // This works now because we changed Allocator.Temp to Allocator.TempJob // in the physics hot path, allowing custom threads to execute physics. _physicsEngine.ExecuteTick((ulong)_simulationTimeUsec); + ProcessPlumbTiltEvents(); + } + } + + /// + /// Queues physics-produced plumb-bob tilt edges for the authored table + /// component that owns switch dispatch. + /// + /// + /// Main-thread gamelogic engines receive these through the same external + /// switch queue as regular main-thread input so switch state, wires, and ROM + /// input stay in sync. + /// + private void ProcessPlumbTiltEvents() + { + if (MainThreadTiltDispatcher == null || DispatchPhysicalTiltInputToMainThread) { + return; + } + + _pendingPlumbTiltEvents.Clear(); + _physicsEngine.DrainPlumbTiltEvents(_pendingPlumbTiltEvents); + if (_pendingPlumbTiltEvents.Count == 0) { + return; + } + + for (var i = 0; i < _pendingPlumbTiltEvents.Count; i++) { + QueueMainThreadTiltState(_pendingPlumbTiltEvents[i]); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index cf186e56c..7cdde2bb0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -5,13 +5,182 @@ // SPDX-License-Identifier: GPL-3.0-or-later using System; +using System.Collections.Generic; using System.Diagnostics; using NLog; using UnityEngine; +using VisualPinball.Engine.Common; using Logger = NLog.Logger; namespace VisualPinball.Unity.Simulation { + /// + /// Serialized nudge sensor settings stored on . + /// + /// + /// The component stores mappings as strings so they can be packed with the + /// table and survive native device ids that contain punctuation. At runtime + /// converts these strings into managed + /// objects used by the physics engine. + /// + [Serializable] + public sealed class SimulationThreadNudgeSensorConfig + { + public NudgeSensorType Type = NudgeSensorType.CabinetDirect; + + [Range(0f, 2f)] + public float Strength = 1f; + + [Range(0f, 200f)] + public float CabinetMassKg = 113f; + + [Tooltip("Rotates the sensor board X/Y axes into cabinet coordinates.")] + public NudgeSensorMountRotation MountRotation = NudgeSensorMountRotation.Rotation0; + + [Tooltip("Mirrors the sensor board X axis before applying mount rotation.")] + public bool MountMirror; + + public string X = string.Empty; + public string Y = string.Empty; + public string AccelerationX = string.Empty; + public string AccelerationY = string.Empty; + public string VelocityX = string.Empty; + public string VelocityY = string.Empty; + + /// + /// Clamps numeric values and normalizes null mapping strings. + /// + public void Normalize() + { + Strength = Mathf.Clamp(Strength, 0f, 2f); + CabinetMassKg = Mathf.Clamp(CabinetMassKg <= 0f ? 113f : CabinetMassKg, 0f, 200f); + MountRotation = NudgeSensorMountTransform.NormalizeRotation(MountRotation); + X ??= string.Empty; + Y ??= string.Empty; + AccelerationX ??= string.Empty; + AccelerationY ??= string.Empty; + VelocityX ??= string.Empty; + VelocityY ??= string.Empty; + } + + /// + /// Converts serialized component settings to the runtime nudge config. + /// + public NudgeSensorConfig ToEngineConfig() + { + return ToCabinetSettings().ToEngineConfig(); + } + + /// + /// Converts this component-specific sensor shape into the shared cabinet + /// input settings object. + /// + public CabinetNudgeSensorSettings ToCabinetSettings() + { + return CabinetNudgeSensorSettings.From(this); + } + + /// + /// Captures the current raw value of every mapped axis as its neutral + /// center. + /// + /// The number of mappings whose raw center was updated. + public int CalibrateRawCenters(IReadOnlyList devices) + { + var count = 0; + count += CalibrateRawCenter(ref X, devices); + count += CalibrateRawCenter(ref Y, devices); + count += CalibrateRawCenter(ref AccelerationX, devices); + count += CalibrateRawCenter(ref AccelerationY, devices); + count += CalibrateRawCenter(ref VelocityX, devices); + count += CalibrateRawCenter(ref VelocityY, devices); + return count; + } + + /// + /// Clears saved neutral centers from every mapping. + /// + public void ResetRawCenters() + { + ResetRawCenter(ref X); + ResetRawCenter(ref Y); + ResetRawCenter(ref AccelerationX); + ResetRawCenter(ref AccelerationY); + ResetRawCenter(ref VelocityX); + ResetRawCenter(ref VelocityY); + } + + /// + /// Builds a serialized mapping from a native device/axis pair. + /// + internal static string BuildMapping(NativeInputDeviceInfo device, NativeInputAxisInfo axis, + SensorMappingKind kind, float scale, float rawCenter) + { + return new SensorMapping { + DeviceId = device.Id, + AxisId = axis.AxisId, + Kind = kind, + DeadZone = 0.02f, + Scale = scale, + Limit = 1f, + RawCenter = rawCenter + }.ToString(); + } + + /// + /// Updates one serialized mapping with the current raw axis center. + /// + private static int CalibrateRawCenter(ref string value, IReadOnlyList devices) + { + if (!SensorMapping.TryParse(value, out var mapping)) { + return 0; + } + if (!TryFindAxis(devices, mapping.DeviceId, mapping.AxisId, out var axis)) { + return 0; + } + mapping.RawCenter = axis.RawValue; + value = mapping.ToString(); + return 1; + } + + /// + /// Clears one serialized mapping's raw center without changing device/axis + /// identity. + /// + private static void ResetRawCenter(ref string value) + { + if (!SensorMapping.TryParse(value, out var mapping)) { + return; + } + mapping.RawCenter = 0f; + value = mapping.ToString(); + } + + /// + /// Finds the current axis snapshot for a serialized mapping. + /// + private static bool TryFindAxis(IReadOnlyList devices, string deviceId, int axisId, + out NativeInputAxisInfo axis) + { + if (devices != null) { + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + if (device.Id != deviceId || device.Axes == null) { + continue; + } + for (var j = 0; j < device.Axes.Count; j++) { + if (device.Axes[j].AxisId == axisId) { + axis = device.Axes[j]; + return true; + } + } + } + } + axis = default; + return false; + } + } + /// /// Unity component that manages the high-performance simulation thread. /// Add this to your table GameObject to enable sub-millisecond input latency. @@ -30,6 +199,25 @@ public class SimulationThreadComponent : MonoBehaviour, IPackable private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const string LogPrefix = "[VPE]"; + public static SimulationThreadComponent EnsureFor(PhysicsEngine physicsEngine) + { + if (!physicsEngine) { + return null; + } + + return physicsEngine.GetComponent() + ?? physicsEngine.gameObject.AddComponent(); + } + + public static SimulationThreadComponent EnsureForTable(GameObject tableRoot) + { + if (!tableRoot) { + return null; + } + + return EnsureFor(tableRoot.GetComponentInChildren(true)); + } + #region Inspector Fields [Header("Simulation Settings")] @@ -39,10 +227,14 @@ public class SimulationThreadComponent : MonoBehaviour, IPackable [Tooltip("Enable native input polling (requires the VisualPinball.NativeInput native plugin for the current platform)")] public bool EnableNativeInput = true; - [Tooltip("Input polling interval in microseconds (default 500μs = 2000 Hz)")] + [Tooltip("Input polling interval in microseconds (default 500 us = 2000 Hz)")] [Range(100, 2000)] public int InputPollingIntervalUs = 500; + [Header("Nudge Sensors")] + [Tooltip("Default analog nudge sensor mappings used by editor/direct play mode. The player app can still override these from its user config.")] + public List NudgeSensors = new(); + [Header("Debug")] [Tooltip("Show simulation statistics in console")] public bool ShowStatistics = false; @@ -71,6 +263,7 @@ public class SimulationThreadComponent : MonoBehaviour, IPackable public float SimulationThreadHz => _simulationThreadHz; public float InputThreadTargetHz => _inputManager?.TargetPollingHz ?? 0f; public float InputThreadActualHz => _inputManager?.ActualEventRateHz ?? 0f; + public bool IsRunning => _started; #endregion @@ -153,7 +346,8 @@ private void OnApplicationQuit() #region Public API /// - /// Start the simulation thread + /// Starts the simulation thread, native input polling, and external physics + /// timing. /// public void StartSimulation() { @@ -178,12 +372,14 @@ public void StartSimulation() // Enable external timing on PhysicsEngine // This disables Unity's Update() loop and gives control to the simulation thread _physicsEngine.SetExternalTiming(true); + ApplyNudgeSensorSettings(); // Create simulation thread _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine, player != null ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) : null); + ConfigureTiltBobRouting(); _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); // Provide the triple-buffered SimulationState to PhysicsEngine so @@ -197,6 +393,7 @@ public void StartSimulation() if (_inputManager.Initialize()) { _inputManager.SetSimulationThread(_simulationThread); + _physicsEngine.AttachNativeInputManager(_inputManager); _inputManager.StartPolling(InputPollingIntervalUs); } else @@ -220,12 +417,14 @@ public void StartSimulation() } /// - /// Stop the simulation thread + /// Stops the simulation and input threads and returns the physics engine to + /// Unity-driven timing. /// public void StopSimulation() { if (!_started) return; + _physicsEngine?.DetachNativeInputManager(_inputManager); _inputManager?.StopPolling(); _inputManager?.Dispose(); _inputManager = null; @@ -273,13 +472,265 @@ public SimulationState.Snapshot GetCurrentSnapshot() } /// - /// The measured input→on-screen latency in milliseconds (input poll → flipper visual movement), + /// The measured input-to-on-screen latency in milliseconds (input poll to flipper visual movement), /// averaged over the calls since the last sample. Returns 0 until the first flipper press is observed. /// Works for any table with flippers (independent of the gamelogic engine's coil-latency stats). /// Main-thread only. /// public float SampleInputLatencyMs() => InputLatencyTracker.SampleFlipperLatencyMs(false); + /// + /// Lists native input devices that can be mapped to nudge sensors. + /// + public IReadOnlyList ListNudgeInputDevices() + { + var inputManager = _inputManager ?? NativeInputManager.TryGetExistingInstance(); + return inputManager == null ? Array.Empty() : inputManager.ListDevices(); + } + + /// + /// Captures the current native-input settings from this component and the + /// current nudge settings from the sibling physics engine. + /// + public CabinetInputSettings GetCabinetInputSettings() + { + _physicsEngine ??= GetComponent(); + var settings = new CabinetInputSettings { + enableNativeInput = EnableNativeInput, + inputPollingIntervalUs = InputPollingIntervalUs, + nudge = _physicsEngine != null ? _physicsEngine.GetNudgeSettings() : new CabinetNudgeSettings() + }; + settings.nudge.sensors = CabinetNudgeSensorSettings.FromSimulationThreadSensors(NudgeSensors); + settings.Normalize(); + return settings; + } + + /// + /// Applies shared cabinet-input settings to this component and, optionally, + /// to the sibling physics engine. + /// + public void ApplyCabinetInputSettings(CabinetInputSettings settings, bool applyNudgeToPhysics = true) + { + settings ??= new CabinetInputSettings(); + settings.Normalize(); + + EnableNativeInput = settings.enableNativeInput; + InputPollingIntervalUs = settings.inputPollingIntervalUs; + ApplyNudgeSettings(settings.nudge, applyNudgeToPhysics); + ApplyNativeInputSettingsIfRunning(); + } + + /// + /// Applies only the shared nudge settings, keeping native input polling + /// options unchanged. + /// + public void ApplyNudgeSettings(CabinetNudgeSettings settings, bool applyNudgeToPhysics = true) + { + settings ??= new CabinetNudgeSettings(); + settings.Normalize(); + + NudgeSensors = settings.ToSimulationThreadSensorConfigs(); + ApplyNudgeSensorSettings(); + + if (applyNudgeToPhysics) { + _physicsEngine ??= GetComponent(); + settings.ApplyTo(_physicsEngine); + } else { + ConfigureTiltBobRouting(); + } + } + + /// + /// Routes plumb tilt edges between the simulation thread and the table's tilt-bob + /// component that owns the authored switch mapping. + /// + internal void ConfigureTiltBobRouting(TiltBobComponent tiltBob = null) + { + if (_simulationThread == null) { + return; + } + + tiltBob ??= FindTiltBobComponent(); + if (tiltBob != null && (tiltBob.UsesSimulatedPlumb || tiltBob.UsesPhysicalTiltInput)) { + _simulationThread.MainThreadTiltDispatcher = tiltBob.QueueTiltStateFromSimulationThread; + _simulationThread.DispatchPhysicalTiltInputToMainThread = tiltBob.UsesPhysicalTiltInput; + return; + } + + _simulationThread.MainThreadTiltDispatcher = null; + _simulationThread.DispatchPhysicalTiltInputToMainThread = false; + } + + private TiltBobComponent FindTiltBobComponent() + { + _physicsEngine ??= GetComponent(); + if (_physicsEngine != null) { + return TiltBobComponent.FindFor(_physicsEngine); + } + + return GetComponentInParent(true) + ?? GetComponentInChildren(true); + } + + /// + /// Reconciles native input polling with the current component fields when + /// settings are changed while Play Mode is already running. + /// + private void ApplyNativeInputSettingsIfRunning() + { + if (!_started || _simulationThread == null) { + return; + } + + if (!EnableNativeInput) { + _physicsEngine?.DetachNativeInputManager(_inputManager); + _inputManager?.StopPolling(); + return; + } + + _inputManager ??= NativeInputManager.Instance; + if (!_inputManager.Initialize()) { + Logger.Warn($"{LogPrefix} [SimulationThreadComponent] Native input not available, falling back to Unity Input System"); + return; + } + + _inputManager.SetSimulationThread(_simulationThread); + _physicsEngine?.AttachNativeInputManager(_inputManager); + var pollingIntervalUs = InputPollingIntervalUs; +#if UNITY_EDITOR + if (pollingIntervalUs < 1000) { + pollingIntervalUs = 1000; + } +#endif + if (_inputManager.IsPolling) { + var targetPollingHz = pollingIntervalUs > 0 ? 1000000f / pollingIntervalUs : 0f; + if (Mathf.Abs(_inputManager.TargetPollingHz - targetPollingHz) < 0.1f) { + return; + } + _inputManager.StopPolling(); + } + _inputManager.StartPolling(InputPollingIntervalUs); + } + + /// + /// Pushes serialized nudge sensor settings into the physics engine. + /// + public void ApplyNudgeSensorSettings() + { + if (_physicsEngine == null) { + _physicsEngine = GetComponent(); + } + if (_physicsEngine == null) { + return; + } + + NudgeSensors ??= new List(); + if (NudgeSensors.Count > NudgeState.MaxSensors) { + NudgeSensors.RemoveRange(NudgeState.MaxSensors, NudgeSensors.Count - NudgeState.MaxSensors); + } + + var configs = new List(NudgeSensors.Count); + for (var i = 0; i < NudgeSensors.Count; i++) { + NudgeSensors[i] ??= new SimulationThreadNudgeSensorConfig(); + configs.Add(NudgeSensors[i].ToEngineConfig()); + } + _physicsEngine.ConfigureNudgeSensors(configs); + } + + /// + /// Calibrates all mapped nudge channels around their current raw input + /// values. + /// + /// + /// This is intentionally a center capture, not a gain calibration pass. It + /// solves the common KL25Z/Pinscape case where a resting accelerometer does + /// not report exactly zero, while the physics-side gain calibrator continues + /// to learn velocity/acceleration scale from motion. + /// + public int CalibrateNudgeSensorCenters() + { + var devices = ListNudgeInputDevices(); + var calibrated = 0; + if (NudgeSensors != null) { + foreach (var sensor in NudgeSensors) { + calibrated += sensor?.CalibrateRawCenters(devices) ?? 0; + } + } + if (calibrated > 0) { + ApplyNudgeSensorSettings(); + } + return calibrated; + } + + /// + /// Clears saved raw centers for all nudge mappings. + /// + public int ResetNudgeSensorCenters() + { + var reset = 0; + if (NudgeSensors != null) { + foreach (var sensor in NudgeSensors) { + if (sensor == null) { + continue; + } + sensor.ResetRawCenters(); + reset++; + } + } + if (reset > 0) { + ApplyNudgeSensorSettings(); + } + return reset; + } + + /// + /// Attempts to map the first connected cabinet-style device to acceleration + /// X/Y channels. + /// + /// + /// The heuristic prefers HID X/Y accelerometer usages, then falls back to + /// named X/Y axes, and finally any two non-Z acceleration axes. This keeps + /// one-click setup useful for KL25Z/Pinscape boards without hard-coding a + /// vendor-specific device name. + /// + public bool TryAutoConfigureFirstCabinetSensor(out string message) + { + var devices = ListNudgeInputDevices(); + for (var i = 0; i < devices.Count; i++) { + var device = devices[i]; + if (!device.IsConnected || device.Axes == null) { + continue; + } + if (TryPickAxisPair(device, out var xAxis, out var yAxis)) { + NudgeSensors ??= new List(); + var existingSensor = NudgeSensors.Count == 0 ? null : NudgeSensors[0]; + var sensor = new SimulationThreadNudgeSensorConfig { + Type = NudgeSensorType.CabinetDirect, + Strength = 1f, + CabinetMassKg = 113f, + MountRotation = existingSensor?.MountRotation ?? NudgeSensorMountRotation.Rotation0, + MountMirror = existingSensor?.MountMirror ?? false, + AccelerationX = SimulationThreadNudgeSensorConfig.BuildMapping(device, xAxis, + SensorMappingKind.Acceleration, 9.81f, xAxis.RawValue), + AccelerationY = SimulationThreadNudgeSensorConfig.BuildMapping(device, yAxis, + SensorMappingKind.Acceleration, 9.81f, yAxis.RawValue) + }; + if (NudgeSensors.Count == 0) { + NudgeSensors.Add(sensor); + } else { + NudgeSensors[0] = sensor; + } + ApplyNudgeSensorSettings(); + var deviceName = string.IsNullOrEmpty(device.Name) ? device.Id : device.Name; + message = $"Mapped {deviceName} axes {AxisName(xAxis)}/{AxisName(yAxis)}."; + return true; + } + } + + message = "No connected input device with two usable axes was found."; + return false; + } + internal bool EnqueueSwitchFromMainThread(string switchId, bool isClosed) { if (!_started || _simulationThread == null) { @@ -336,6 +787,102 @@ private void LogStatistics(in SimulationState.Snapshot state) private static long TimestampUsec => (Stopwatch.GetTimestamp() * 1_000_000L) / Stopwatch.Frequency; + /// + /// Picks a likely X/Y axis pair from one native device. + /// + private static bool TryPickAxisPair(NativeInputDeviceInfo device, out NativeInputAxisInfo xAxis, + out NativeInputAxisInfo yAxis) + { + xAxis = default; + yAxis = default; + if (TryFindAxis(device, "X", 0x30, NativeInputApi.AxisKind.Acceleration, out xAxis) && + TryFindAxis(device, "Y", 0x31, NativeInputApi.AxisKind.Acceleration, out yAxis) && + xAxis.AxisId != yAxis.AxisId) { + return true; + } + + var xSet = TryFindAxis(device, "X", 0x30, null, out xAxis); + var ySet = TryFindAxis(device, "Y", 0x31, null, out yAxis); + if (xSet && ySet && xAxis.AxisId != yAxis.AxisId) { + return true; + } + + for (var i = 0; i < device.Axes.Count; i++) { + var axis = device.Axes[i]; + if (axis.Kind != NativeInputApi.AxisKind.Acceleration || IsNamedAxis(axis, "Z", 0x32)) { + continue; + } + if ((xSet && axis.AxisId == xAxis.AxisId) || (ySet && axis.AxisId == yAxis.AxisId)) { + continue; + } + if (!xSet) { + xAxis = axis; + xSet = true; + } else { + yAxis = axis; + ySet = true; + break; + } + } + + return xSet && ySet && xAxis.AxisId != yAxis.AxisId; + } + + /// + /// Finds an axis by HID usage or display name, optionally constrained by + /// reported axis kind. + /// + private static bool TryFindAxis(NativeInputDeviceInfo device, string axisName, int usage, + NativeInputApi.AxisKind? kind, out NativeInputAxisInfo axis) + { + if (device.Axes != null) { + for (var i = 0; i < device.Axes.Count; i++) { + var candidate = device.Axes[i]; + if (kind.HasValue && candidate.Kind != kind.Value) { + continue; + } + if (IsNamedAxis(candidate, axisName, usage)) { + axis = candidate; + return true; + } + } + } + axis = default; + return false; + } + + /// + /// Checks whether a native axis looks like the requested X/Y/Z axis. + /// + private static bool IsNamedAxis(NativeInputAxisInfo axis, string axisName, int usage) + { + if (axis.Usage == usage) { + return true; + } + + var name = axis.Name; + if (string.IsNullOrEmpty(name)) { + return false; + } + if (string.Equals(name, axisName, StringComparison.OrdinalIgnoreCase)) { + return true; + } + return name.EndsWith(" " + axisName, StringComparison.OrdinalIgnoreCase) + || name.EndsWith("-" + axisName, StringComparison.OrdinalIgnoreCase) + || name.EndsWith("_" + axisName, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns a human-readable axis label for inspector messages. + /// + private static string AxisName(NativeInputAxisInfo axis) + { + return string.IsNullOrEmpty(axis.Name) ? $"Axis {axis.AxisId}" : axis.Name; + } + + /// + /// Updates smoothed simulation speed diagnostics for the inspector. + /// private void UpdateSimulationSpeed(in SimulationState.Snapshot state) { var now = Time.unscaledTime; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs index 9b64f5934..adcaea247 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponentPackable.cs @@ -16,33 +16,135 @@ namespace VisualPinball.Unity.Simulation { + /// + /// Packaged representation of one serialized nudge sensor. + /// + /// + /// Mapping strings are persisted as-is because they already encode device id, + /// axis id, channel kind, center, dead-zone, scale, and limit. + /// + public struct SimulationThreadNudgeSensorPackable + { + public NudgeSensorType Type; + public float Strength; + public float CabinetMassKg; + public NudgeSensorMountRotation MountRotation; + public bool MountMirror; + public string X; + public string Y; + public string AccelerationX; + public string AccelerationY; + public string VelocityX; + public string VelocityY; + + /// + /// Copies a component sensor config into package data. + /// + public static SimulationThreadNudgeSensorPackable Pack(SimulationThreadNudgeSensorConfig sensor) + { + sensor ??= new SimulationThreadNudgeSensorConfig(); + sensor.Normalize(); + return new SimulationThreadNudgeSensorPackable { + Type = sensor.Type, + Strength = sensor.Strength, + CabinetMassKg = sensor.CabinetMassKg, + MountRotation = sensor.MountRotation, + MountMirror = sensor.MountMirror, + X = sensor.X, + Y = sensor.Y, + AccelerationX = sensor.AccelerationX, + AccelerationY = sensor.AccelerationY, + VelocityX = sensor.VelocityX, + VelocityY = sensor.VelocityY + }; + } + + /// + /// Rehydrates package data into a component sensor config. + /// + public SimulationThreadNudgeSensorConfig Unpack() + { + return new SimulationThreadNudgeSensorConfig { + Type = Type, + Strength = Strength, + CabinetMassKg = CabinetMassKg, + MountRotation = MountRotation, + MountMirror = MountMirror, + X = X, + Y = Y, + AccelerationX = AccelerationX, + AccelerationY = AccelerationY, + VelocityX = VelocityX, + VelocityY = VelocityY + }; + } + } + + /// + /// Packaged representation of . + /// public struct SimulationThreadComponentPackable { public bool EnableSimulationThread; public bool EnableNativeInput; public int InputPollingIntervalUs; + public bool HasNudgeSensors; + public SimulationThreadNudgeSensorPackable[] NudgeSensors; public bool ShowStatistics; public float StatisticsInterval; + /// + /// Serializes simulation-thread component settings for table packages. + /// public static byte[] Pack(SimulationThreadComponent comp) { return PackageApi.Packer.Pack(new SimulationThreadComponentPackable { EnableSimulationThread = comp.EnableSimulationThread, EnableNativeInput = comp.EnableNativeInput, InputPollingIntervalUs = comp.InputPollingIntervalUs, + HasNudgeSensors = true, + NudgeSensors = PackNudgeSensors(comp), ShowStatistics = comp.ShowStatistics, StatisticsInterval = comp.StatisticsInterval, }); } + /// + /// Restores simulation-thread component settings from table package data. + /// public static void Unpack(byte[] bytes, SimulationThreadComponent comp) { var data = PackageApi.Packer.Unpack(bytes); comp.EnableSimulationThread = data.EnableSimulationThread; comp.EnableNativeInput = data.EnableNativeInput; comp.InputPollingIntervalUs = data.InputPollingIntervalUs; + if (data.HasNudgeSensors) { + comp.NudgeSensors ??= new System.Collections.Generic.List(); + comp.NudgeSensors.Clear(); + if (data.NudgeSensors != null) { + for (var i = 0; i < data.NudgeSensors.Length && i < NudgeState.MaxSensors; i++) { + comp.NudgeSensors.Add(data.NudgeSensors[i].Unpack()); + } + } + } comp.ShowStatistics = data.ShowStatistics; comp.StatisticsInterval = data.StatisticsInterval; } + + /// + /// Packs up to the maximum supported number of nudge sensors. + /// + private static SimulationThreadNudgeSensorPackable[] PackNudgeSensors(SimulationThreadComponent comp) + { + if (comp.NudgeSensors == null || comp.NudgeSensors.Count == 0) { + return null; + } + var count = comp.NudgeSensors.Count > NudgeState.MaxSensors ? NudgeState.MaxSensors : comp.NudgeSensors.Count; + var result = new SimulationThreadNudgeSensorPackable[count]; + for (var i = 0; i < count; i++) { + result[i] = SimulationThreadNudgeSensorPackable.Pack(comp.NudgeSensors[i]); + } + return result; + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs index a1c25c9a5..ab9e4d1b4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs @@ -17,6 +17,7 @@ // ReSharper disable InconsistentNaming using System; +using System.Linq; using UnityEngine; using UnityEngine.Serialization; @@ -43,7 +44,15 @@ protected override bool TryFindEventSource(out IApiCoil coil) } foreach (var component in GetComponents()) { - coil = player.Coil(component, CoilName); + var coilName = CoilName; + if (string.IsNullOrEmpty(coilName)) { + var availableCoils = component.AvailableCoils.ToArray(); + if (availableCoils.Length != 1) { + continue; + } + coilName = availableCoils[0].Id; + } + coil = player.Coil(component, coilName); if (coil != null) { return true; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index 937b182b2..ae9d9d2fd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -145,7 +145,7 @@ the angular velocity used here is not the angular velocity that the ball has (wh */ } - public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity); + public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity, float2.zero); public override string ToString() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs index 2a72c0acb..c47d0a1cc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallVelocityPhysics.cs @@ -21,13 +21,13 @@ namespace VisualPinball.Unity { - internal static class BallVelocityPhysics - { - public static void UpdateVelocities(ref BallState ball, float3 gravity) - { - if (ball.IsFrozen) { - return; - } + internal static class BallVelocityPhysics + { + public static void UpdateVelocities(ref BallState ball, float3 gravity, float2 cabinetAcceleration) + { + if (ball.IsFrozen) { + return; + } // A resting ball must stop rotating. The point-contact model has no // drilling friction, and the per-contact friction impulses that hold the @@ -54,9 +54,12 @@ public static void UpdateVelocities(ref BallState ball, float3 gravity) math.max(-10.0f, math.min(10.0f, (ball.ManualPosition.y - ball.Position.y) * (float)(1.0/10.0))), -2.0f ); - } else { - ball.Velocity += PhysicsConstants.PhysFactor * gravity; - } - } - } -} + } else { + ball.Velocity += PhysicsConstants.PhysFactor * gravity; + var nudgeVelocity = PhysicsConstants.PhysFactor * PhysicsConstants.Ms2ToVpuVpt2 * cabinetAcceleration; + ball.Velocity.x -= nudgeVelocity.x; + ball.Velocity.y -= nudgeVelocity.y; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs index 752c457ee..63d3e3df9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs @@ -44,8 +44,9 @@ public class TableComponent : MainRenderableComponent, IPackable #region Data - public float GlobalDifficulty = 0.2f; - public int OverridePhysics; + public float GlobalDifficulty = 0.2f; + public int OverridePhysics; + public float NudgeTime = 5f; #endregion @@ -109,9 +110,10 @@ public void RestoreCollections(List collections) public override IEnumerable SetData(TableData data) { - GlobalDifficulty = data.GlobalDifficulty; - OverridePhysics = data.OverridePhysics; - return new List { this }; + GlobalDifficulty = data.GlobalDifficulty; + OverridePhysics = data.OverridePhysics; + NudgeTime = data.NudgeTime; + return new List { this }; } public override IEnumerable SetReferencedData(TableData data, Table table, IMaterialProvider materialProvider, ITextureProvider textureProvider, Dictionary components) @@ -122,11 +124,12 @@ public override IEnumerable SetReferencedData(TableData data, Tab public override TableData CopyDataTo(TableData data, string[] materialNames, string[] textureNames, bool forExport) { data.TableHeight = 0; - data.GlobalDifficulty = GlobalDifficulty; - data.OverridePhysics = OverridePhysics; - - return data; - } + data.GlobalDifficulty = GlobalDifficulty; + data.OverridePhysics = OverridePhysics; + data.NudgeTime = NudgeTime; + + return data; + } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob.meta new file mode 100644 index 000000000..1d1d5338c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9e0f54d099684344b9cf47b9bbfe6dd0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs new file mode 100644 index 000000000..14719b00a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs @@ -0,0 +1,64 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Runtime switch device for the table tilt-bob component. + /// + internal sealed class TiltBobApi : IApi, IApiSwitch, IApiSwitchDevice + { + private readonly SwitchHandler _switchHandler; + + public event EventHandler Init; + public event EventHandler Switch; + + public bool IsSwitchEnabled => _switchHandler.IsEnabled; + + public TiltBobApi(GameObject gameObject, Player player, PhysicsEngine physicsEngine) + { + _switchHandler = new SwitchHandler(gameObject.name, player, physicsEngine); + } + + public void SetSwitch(bool enabled) + { + _switchHandler.OnSwitch(enabled); + Switch?.Invoke(this, new SwitchEventArgs(enabled)); + } + + IApiSwitchStatus IApiSwitch.AddSwitchDest(SwitchConfig switchConfig, IApiSwitchStatus switchStatus) => + _switchHandler.AddSwitchDest(switchConfig.WithPulse(false).WithDefault(SwitchDefault.NormallyOpen), switchStatus); + + void IApiSwitch.AddWireDest(WireDestConfig wireConfig) => _switchHandler.AddWireDest(wireConfig.WithPulse(false)); + + void IApiSwitch.RemoveWireDest(string destId) => _switchHandler.RemoveWireDest(destId); + + IApiSwitch IApiSwitchDevice.Switch(string deviceItem) => + deviceItem == TiltBobComponent.SwitchItem ? this : null; + + void IApi.OnInit(BallManager ballManager) + { + Init?.Invoke(this, EventArgs.Empty); + } + + void IApi.OnDestroy() + { + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs.meta new file mode 100644 index 000000000..9ca73269a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f6d364cb1794b2b841848b029257a9c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs new file mode 100644 index 000000000..b9aeb9dab --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs @@ -0,0 +1,244 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Serialization; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity +{ + /// + /// Table-owned tilt-bob switch device. + /// + /// + /// The component is the table author's routing point: map the game logic's + /// tilt switch to this device if the table should have a tilt bob. The + /// player's cabinet settings decide whether the signal comes from the physics + /// plumb-bob simulation or from a real physical cabinet switch. + /// + [DisallowMultipleComponent] + [PackAs("TiltBob")] + [AddComponentMenu("Pinball/Mechs/Tilt Bob")] + public sealed class TiltBobComponent : MonoBehaviour, ISwitchDeviceComponent, IPackable + { + public const string SwitchItem = "tilt_bob_switch"; + public const float DefaultDamping = 1f; + public const float MinDamping = 0f; + public const float MaxDamping = 2f; + public const float DefaultThresholdAngle = 2f; + public const float MinThresholdAngle = 0.5f; + public const float MaxThresholdAngle = 4f; + + private readonly Queue _queuedTiltStates = new(); + private readonly List _pendingSimulatedTiltStates = new(8); + private readonly object _queuedTiltLock = new(); + + [Tooltip("Mechanical plumb-bob damping scale. Higher values calm the bob faster after a nudge.")] + [Range(MinDamping, MaxDamping)] + [FormerlySerializedAs("Damping")] + public float PlumbDamping = DefaultDamping; + + [Tooltip("Mechanical plumb-bob tilt threshold angle in degrees. Lower values make the table easier to tilt.")] + [Range(MinThresholdAngle, MaxThresholdAngle)] + [FormerlySerializedAs("ThresholdAngle")] + public float PlumbThresholdAngle = DefaultThresholdAngle; + + [NonSerialized] private Player _player; + [NonSerialized] private PhysicsEngine _physicsEngine; + [NonSerialized] private TiltBobApi _api; + [NonSerialized] private TiltBobMode _mode = TiltBobMode.Simulated; + [NonSerialized] private bool _enabled = true; + [NonSerialized] private bool _settingsApplied; + + public IEnumerable AvailableSwitches => new[] { + new GamelogicEngineSwitch(SwitchItem) { Description = "Tilt Bob" } + }; + + public SwitchDefault SwitchDefault => SwitchDefault.NormallyOpen; + + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableSwitches; + + public TiltBobMode Mode => _mode; + public bool UsesSimulatedPlumb => _enabled && _mode == TiltBobMode.Simulated; + public bool UsesPhysicalTiltInput => _enabled && _mode == TiltBobMode.Physical; + + public static TiltBobComponent FindFor(PhysicsEngine physicsEngine) + { + if (!physicsEngine) { + return null; + } + + var table = physicsEngine.GetComponentInParent(); + if (table) { + return table.GetComponentInChildren(true); + } + + return physicsEngine.GetComponentInParent(true) + ?? physicsEngine.GetComponentInChildren(true); + } + + public static void ApplySettings(PhysicsEngine physicsEngine, CabinetPlumbSettings settings) + { + settings ??= new CabinetPlumbSettings(); + settings.Normalize(); + + var tiltBob = FindFor(physicsEngine); + if (tiltBob != null) { + tiltBob.ApplySettings(settings); + return; + } + + // Without a table tilt-bob component there is no switch route, so keep + // the physics plumb disabled even if the player prefers simulation. + physicsEngine?.ConfigurePlumb(false, DefaultDamping, DefaultThresholdAngle); + } + + public byte[] Pack() => TiltBobPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => null; + + public void Unpack(byte[] bytes) => TiltBobPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, PackagedFiles files) { } + + public void ApplySettings(CabinetPlumbSettings settings) + { + settings ??= new CabinetPlumbSettings(); + settings.Normalize(); + + _enabled = settings.enabled; + _mode = settings.mode; + _settingsApplied = true; + + ConfigurePhysicsPlumb(); + ConfigureSimulationThreadRouting(); + } + + internal void QueueTiltStateFromSimulationThread(bool enabled) + { + lock (_queuedTiltLock) { + _queuedTiltStates.Enqueue(enabled); + } + } + + private void Awake() + { + _player = GetComponentInParent(); + _physicsEngine = GetComponentInParent(); + if (_player != null && _physicsEngine != null) { + _api = new TiltBobApi(gameObject, _player, _physicsEngine); + _player.Register(_api, this); + _player.CabinetInputActionChanged += OnCabinetInputActionChanged; + } + } + + private void Start() + { + if (!_settingsApplied) { + ApplySettings(new CabinetPlumbSettings()); + } + } + + private void Update() + { + FlushQueuedTiltStates(); + + if (!UsesSimulatedPlumb || !_physicsEngine || _physicsEngine.UsesExternalTiming) { + return; + } + + _pendingSimulatedTiltStates.Clear(); + _physicsEngine.DrainPlumbTiltEvents(_pendingSimulatedTiltStates); + foreach (var tilted in _pendingSimulatedTiltStates) { + SetSwitch(tilted); + } + } + + private void OnDestroy() + { + _enabled = false; + if (_player != null) { + _player.CabinetInputActionChanged -= OnCabinetInputActionChanged; + } + _physicsEngine?.ConfigurePlumb(false, DefaultDamping, DefaultThresholdAngle); + ConfigureSimulationThreadRouting(); + } + + private void OnValidate() + { + NormalizeSettings(); + if (Application.isPlaying && _settingsApplied) { + ConfigurePhysicsPlumb(); + } + } + + internal void NormalizeSettings() + { + PlumbDamping = Mathf.Clamp(PlumbDamping, MinDamping, MaxDamping); + PlumbThresholdAngle = Mathf.Clamp(PlumbThresholdAngle, MinThresholdAngle, MaxThresholdAngle); + } + + private void OnCabinetInputActionChanged(string actionName, bool isPressed) + { + if (UsesPhysicalTiltInput && actionName == InputConstants.ActionTilt) { + SetSwitch(isPressed); + } + } + + private void FlushQueuedTiltStates() + { + while (true) { + bool tilted; + lock (_queuedTiltLock) { + if (_queuedTiltStates.Count == 0) { + return; + } + tilted = _queuedTiltStates.Dequeue(); + } + SetSwitch(tilted); + } + } + + private void SetSwitch(bool enabled) + { + if (!_enabled) { + return; + } + _api?.SetSwitch(enabled); + } + + private void ConfigurePhysicsPlumb() + { + NormalizeSettings(); + _physicsEngine ??= GetComponentInParent(); + _physicsEngine?.ConfigurePlumb(UsesSimulatedPlumb, PlumbDamping, PlumbThresholdAngle); + } + + private void ConfigureSimulationThreadRouting() + { + var simulationThread = _physicsEngine != null + ? _physicsEngine.GetComponent() + ?? _physicsEngine.GetComponentInParent() + ?? _physicsEngine.GetComponentInChildren() + : null; + simulationThread?.ConfigureTiltBobRouting(this); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta new file mode 100644 index 000000000..bdab21763 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2ce2fd55a40b4f549bf7a32ed518e58b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs new file mode 100644 index 000000000..2753c6e12 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs @@ -0,0 +1,36 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + /// + /// Selects where a table's tilt-bob switch signal comes from. + /// + public enum TiltBobMode + { + /// + /// Use the physics engine's simulated plumb-bob edges generated from + /// cabinet acceleration. + /// + Simulated = 0, + + /// + /// Use the player's physical cabinet tilt input, typically a real plumb bob + /// wired into the cabinet controller. + /// + Physical = 1 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs.meta new file mode 100644 index 000000000..aad8117cb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobMode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b4bda9d8e6747559a48db5b10bb618d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs new file mode 100644 index 000000000..f7e6c5e3e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs @@ -0,0 +1,48 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + /// + /// Packaged representation of table-authored tilt-bob behavior. + /// + public struct TiltBobPackable + { + public float Damping; + public float ThresholdAngle; + + public static byte[] Pack(TiltBobComponent comp) + { + comp.NormalizeSettings(); + return PackageApi.Packer.Pack(new TiltBobPackable { + Damping = comp.PlumbDamping, + ThresholdAngle = comp.PlumbThresholdAngle + }); + } + + public static void Unpack(byte[] bytes, TiltBobComponent comp) + { + if (bytes == null || bytes.Length == 0) { + return; + } + + var data = PackageApi.Packer.Unpack(bytes); + comp.PlumbDamping = data.Damping; + comp.PlumbThresholdAngle = data.ThresholdAngle; + comp.NormalizeSettings(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs.meta new file mode 100644 index 000000000..ee55fd5ed --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobPackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a2c4e2f1f0b14a04b8338e9d58ea7d25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: