diff --git a/VisualPinball.Engine/Game/EventId.cs b/VisualPinball.Engine/Game/EventId.cs index ed8e52253..8fb9089c3 100644 --- a/VisualPinball.Engine/Game/EventId.cs +++ b/VisualPinball.Engine/Game/EventId.cs @@ -32,9 +32,15 @@ public enum EventId TargetEventsRaised = 1303, // DISPID_TargetEvents_Raised // Generic - HitEventsHit = 1400, // DISPID_HitEvents_Hit - HitEventsUnhit = 1401, // DISPID_HitEvents_Unhit - LimitEventsEos = 1402, // DISPID_LimitEvents_EOS - LimitEventsBos = 1403, // DISPID_LimitEvents_BOS - } -} + HitEventsHit = 1400, // DISPID_HitEvents_Hit + HitEventsUnhit = 1401, // DISPID_HitEvents_Unhit + LimitEventsEos = 1402, // DISPID_LimitEvents_EOS + LimitEventsBos = 1403, // DISPID_LimitEvents_BOS + + // Magnet + MagnetEventsBallEntered = 1500, + MagnetEventsBallExited = 1501, + MagnetEventsBallGrabbed = 1502, + MagnetEventsBallReleased = 1503, + } +} diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png new file mode 100644 index 000000000..1dc718818 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png.meta new file mode 100644 index 000000000..c92bb3ed4 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 012370602c0f5e041ae29606ed25b58e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png new file mode 100644 index 000000000..71c52c4a2 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png.meta new file mode 100644 index 000000000..f1f0b7f93 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 59543c014f0bcbf4b958eb5401b63a74 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png new file mode 100644 index 000000000..f8dc4efb7 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png.meta new file mode 100644 index 000000000..cd513a071 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: f2c803ec40578724fae8e1b3bd618d2e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png new file mode 100644 index 000000000..9c757223f Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png.meta new file mode 100644 index 000000000..2b5bdf647 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a01fd154e0c47784ea736fe517f93545 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png new file mode 100644 index 000000000..5509f74c8 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png.meta new file mode 100644 index 000000000..83f01031d --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 1ae0acda5255e96479fef9d0e72d7319 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png new file mode 100644 index 000000000..a0b401316 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png.meta new file mode 100644 index 000000000..91c9ace3b --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: d1fa4fb7a6f80a047ac1f8595489696b +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png new file mode 100644 index 000000000..e4536d597 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png.meta new file mode 100644 index 000000000..3d78a81ff --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 35fc6b9ab6bfd7e4082853abcadad07c +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png new file mode 100644 index 000000000..2495fc266 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png.meta new file mode 100644 index 000000000..1a7060e79 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 84a0fc54e5c386a45bd366b815b5f90f +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png new file mode 100644 index 000000000..7bfb3a018 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png.meta new file mode 100644 index 000000000..904abbb7c --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: aaef4fdce4d53774fbd03e50f9a17937 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png new file mode 100644 index 000000000..b683b06c3 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png.meta new file mode 100644 index 000000000..7b9686533 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 3db6fe041a558fd43beaac5844ba7792 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png new file mode 100644 index 000000000..4e420d267 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png.meta new file mode 100644 index 000000000..3498b6136 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 0d9335a763effb345ae11638d84eeb7c +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png new file mode 100644 index 000000000..54064ac8e Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png.meta new file mode 100644 index 000000000..0ba25a82d --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 44157632e169c4841acda7b12bad12a9 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png new file mode 100644 index 000000000..f84a91fde Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png.meta new file mode 100644 index 000000000..640daa0d6 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 33b3a3c280b720742af8f53c484effec +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png new file mode 100644 index 000000000..bd52af486 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png.meta new file mode 100644 index 000000000..921285dbf --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a6ca5931e0c6084468663248d40626a5 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png new file mode 100644 index 000000000..bb9572db4 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png.meta new file mode 100644 index 000000000..05da11a8f --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 414cb591cd512f4469529b9e4c95ba6a +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png new file mode 100644 index 000000000..8b25ae2ba Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png.meta new file mode 100644 index 000000000..f6c39b8be --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 718ce469494cad94db11fb4962aec90e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png new file mode 100644 index 000000000..7c90ef3b9 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png.meta new file mode 100644 index 000000000..de680adef --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a1e05a04d35cae849b53f44e73d83d14 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png new file mode 100644 index 000000000..dbb9f3527 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png.meta new file mode 100644 index 000000000..c42fdf983 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: bd2796541fa61b948887270d6239e265 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png new file mode 100644 index 000000000..909a779a2 Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png.meta new file mode 100644 index 000000000..c46466c76 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 5c38fa0fd9c22dd42a110b21be98044e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png new file mode 100644 index 000000000..96468e09a Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png differ diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png.meta new file mode 100644 index 000000000..c40b680ee --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 8eae765855f5e7445b8bb779125e80ad +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab new file mode 100644 index 000000000..0ac63abc9 --- /dev/null +++ b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab @@ -0,0 +1,59 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &8456101019218624000 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8456101019218624001} + - component: {fileID: 8456101019218624002} + m_Layer: 0 + m_Name: Magnet + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8456101019218624001 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8456101019218624000} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &8456101019218624002 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8456101019218624000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf48c83fc8414ae7bb8c31f1b1854b83, type: 3} + m_Name: + m_EditorClassIdentifier: VisualPinball.Unity::VisualPinball.Unity.MagnetComponent + Radius: 50 + Strength: 10 + MagnetType: 0 + ForceProfile: 0 + CoilRiseTime: 20 + CoilFallTime: 20 + PoleRadius: 10 + GrabBall: 0 + GrabRadius: 10.8 + HeightRange: 50 + IsEnabledOnStart: 0 + IsKinematic: 0 + DrawDebugForces: 0 diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab.meta b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab.meta new file mode 100644 index 000000000..5618822c3 --- /dev/null +++ b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c18118d9d47048d5af0a9f1149d32d19 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md new file mode 100644 index 000000000..4c5014601 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -0,0 +1,96 @@ +--- +uid: magnets +title: Magnets and Turntables +description: Native playfield magnets, ball holds, VPX repel/eject behavior, and spinning disc turntables. +--- + +# Magnets and Turntables + +VPE provides native mechanisms for playfield magnets and spinning magnetic discs. They run inside the physics loop, so they can react every physics tick instead of waiting for a script timer. + +Use **Magnet** for radial attraction, grab/hold behavior, VPX-compatible repulsion, and eject-style mechanisms. Use **Turntable** for a spinning disc that pushes the ball tangentially, such as VPX `cvpmTurnTable` mechanisms. + +## Magnet Setup + +Add the **Magnet** component to a GameObject with *Add Component -> Pinball -> Mechs -> Magnet*. Place the GameObject at the magnet core or hold position. The component uses the transform position as the center of the force field. + +The selected object shows a radius gizmo in the scene view. Playfield magnets draw a cylinder; spatial magnets draw a sphere. If grab is enabled, a smaller grab radius is drawn as well. + +| Field | Description | +|---|---| +| **Magnet Type** | **Playfield** for under-playfield magnets with a cylindrical range, **Spatial** for mech-mounted magnets that grab and carry balls in 3-D. | +| **Radius** | Influence radius in millimeters. Playfield magnets use a planar radius; spatial magnets use a spherical radius. | +| **Height Range** | Vertical window above a playfield magnet. Use this to avoid affecting balls on ramps above the playfield. A value of zero disables the vertical limit. Spatial magnets ignore this field. | +| **Strength** | Full-power magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values and negative values repel. | +| **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that use the finite-pole field model. Spatial magnets always use physical 3-D force semantics. | +| **Pole Radius** | Effective radius of the circular or annular pole face. The Physical force is strongest in a ring near this pole and is zero laterally at the exact center. | +| **Coil Rise Time** | Electrical rise time constant in milliseconds for Physical and Spatial magnets. Current reaches about 63% after one time constant. | +| **Coil Fall Time** | Electrical decay time constant in milliseconds for Physical and Spatial magnets. Tune this to the coil driver and flyback circuit. | +| **Grab Ball** | Enables center hold behavior inside the grab radius. | +| **Grab Radius** | Capture volume for grab mode. VPX Compatible snaps on entry. Physical and Spatial magnets capture only when the current field can arrest the ball before it leaves this volume, then use a spring-damper hold that a hard hit can overcome. | +| **Is Enabled On Start** | Starts the magnet on before a coil or script changes it. | +| **Is Kinematic** | Moves the magnetic field with the GameObject transform during gameplay. Use this when the magnet is mounted on a moving mech. | +| **Draw Debug Forces** | Draws play-mode force vectors for balls inside the radius. | + +## Coils and Switches + +Magnets expose one coil: + +| Device item | Description | +|---|---| +| `magnet_coil` | Enables the magnet while the coil is active. | + +Magnets also expose one switch: + +| Device item | Description | +|---|---| +| `ball_held` | Closes while one or more balls are grabbed by the magnet. | + +Map ROM solenoids to `magnet_coil` in the [Coil Manager](../../editor/coil-manager.md). The `ball_held` switch can be used by game logic when a table needs explicit confirmation that a ball is held. + +Some ROMs pulse-width-modulate (PWM) their magnet coils to vary grip — Iron Man, for example, drives its magnets at partial strength. The normalized coil command travels through the simulation-thread path without being reduced to a boolean. VPX Compatible scales its authored **Strength** directly by that command. Physical and Spatial magnets instead integrate effective coil current using **Coil Rise Time** and **Coil Fall Time**, then derive force from current squared. Set **Strength** to the full-power grip at 100% current. + +## Force Profiles + +### VPX Compatible + +Use **VPX Compatible** for imported tables or ports that already have tuned `cvpmMagnet` values. This profile follows the VPX magnet force curve and normalizes it to VPE's 1 kHz physics loop. + +This mode is the default. It is the right choice for ROM-controlled magnets and Magna-Save style fields. + +### Physical + +Use **Physical** for new VPE-authored tables. Its electrical response ramps toward the commanded current instead of switching the field instantaneously, and the field decays after power is removed. The force uses a finite axisymmetric pole approximation: lateral force is zero on the center axis, strongest in an annulus near **Pole Radius**, weakens with vertical air gap, and falls rapidly outside the pole. **Radius** is a hard performance boundary with a smooth force taper, so the magnet never evaluates an infinite tail. + +Physical magnets always attract an ordinary steel pinball. Reversing coil polarity changes magnetic flux direction but not the direction of attraction. Use VPX Compatible when a legacy script intentionally uses negative strength as a fictional repelling force. + +Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. Capture is based on relative speed, remaining grab distance, and effective field strength; a fast fly-by or weak PWM command does not become an artificial full-strength hold. + +Physical strength values are not VPX strength values. Start with a larger value than you would use in VPX Compatible mode and tune by watching ball speed and catch behavior in play mode. + +## Spatial Magnets + +Use **Magnet Type: Spatial** for mechanisms that physically carry the ball away from the playfield, such as a mouth, hand, or wand mounted on a moving mech. Spatial magnets use a spherical radius around the transform and treat the transform as the ball center hold point. + +When **Grab Ball** is enabled, a ball inside **Grab Radius** is pulled to that 3-D hold point by a strong magnetic force and held there. The ball stays a live physics object throughout — it is not frozen — so it renders at its real position and other balls collide with it normally. If **Is Kinematic** is enabled and the GameObject moves during gameplay, the hold force drags the ball along in x, y, and z. Turning the coil off or calling `ReleaseBall()` simply drops the hold, and the ball continues with whatever velocity it has. `Eject(speed, angleDeg, verticalAngleDeg)` throws it directionally; the first angle uses the same convention as kickers, and the optional vertical angle lifts or drops the shot. + +Because the hold is a force rather than a rigid lock, a hard enough hit from another ball can overcome it and knock the ball loose (and that ball can in turn be grabbed). A new ball is marked held only after the current field can arrest its relative motion inside **Grab Radius**. **Strength** sets how hard the magnet holds — a stronger magnet needs a harder hit to dislodge its ball. This is not a levitation model: use **Radius** and **Strength** to catch a nearby ball, not to pull one across the table or balance it far below the hold point. + +## Turntable Setup + +Add the **Turntable** component with *Add Component -> Pinball -> Mechs -> Turntable*. Place it at the disc center and set **Radius** to cover the area where the spinning disc should affect the ball. **Height Range** bounds the effect vertically so balls on ramps above the disc are unaffected; a value of zero disables the vertical limit. + +Turntables expose two coils: + +| Device item | Description | +|---|---| +| `motor_coil` | Turns the motor on while active. | +| `direction_coil` | Selects clockwise while active and counter-clockwise while inactive. | + +The turntable ramps toward **Max Speed** using **Spin Up**, then ramps back toward zero using **Spin Down** when the motor turns off. Assign **Rotation Target** to the visible disc mesh if you want it to rotate with the simulated speed. + +## Kinematic Magnets + +Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. A held ball is dragged along by the hold force — planar for playfield magnets, full 3-D for spatial magnets. + +Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned for playfield magnets; tilted field axes are not modeled. Spatial magnets are the supported path for carrying a held ball away from the playfield. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index 71fd85e93..2384a7fd8 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -107,8 +107,10 @@ href: manual/mechanisms/plungers.md - name: Slingshots href: manual/mechanisms/slingshots.md - - name: Light Groups - href: manual/mechanisms/light-groups.md + - name: Magnets and Turntables + href: manual/mechanisms/magnets.md + - name: Light Groups + href: manual/mechanisms/light-groups.md - name: Teleporters href: manual/mechanisms/teleporters.md - name: Drop Target Banks diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs index 22efffe72..1332dea4c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs @@ -178,13 +178,20 @@ private void OnGUI() GUILayout.EndHorizontal(); GUILayout.BeginHorizontal(); - if (CreateButton("Slingshot", Icons.Slingshot(color: iconColor), iconSize, buttonStyle)) { - CreatePrefab("Slingshots", "Prefabs/Slingshot"); - } - - if (CreateButton("Metal Wire\nGuide", Icons.MetalWireGuide(color: iconColor), iconSize, buttonStyle)) { - CreateItem(MetalWireGuide.GetDefault, "New MetalWireGuide"); - } + if (CreateButton("Slingshot", Icons.Slingshot(color: iconColor), iconSize, buttonStyle)) { + CreatePrefab("Slingshots", "Prefabs/Slingshot"); + } + + if (CreateButton("Magnet", Icons.Magnet(color: iconColor), iconSize, buttonStyle)) { + CreatePrefab("Magnets", "Prefabs/Magnet"); + } + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + if (CreateButton("Metal Wire\nGuide", Icons.MetalWireGuide(color: iconColor), iconSize, buttonStyle)) { + CreateItem(MetalWireGuide.GetDefault, "New MetalWireGuide"); + } GUILayout.EndHorizontal(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs index 6e1595750..4daf9a470 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs @@ -79,11 +79,12 @@ public IconVariant(string name, IconSize size, IconColor color) private const string HitTargetName = "hit_target"; private const string KeyName = "keyboard"; private const string KickerName = "kicker"; - private const string LightGroupName = "light_group"; - private const string LightName = "light"; - private const string LoopButtonName = "player"; - private const string MechName = "mech"; - private const string MechPinMameName = "mech_pinmame"; + private const string LightGroupName = "light_group"; + private const string LightName = "light"; + private const string LoopButtonName = "player"; + private const string MagnetName = "magnet"; + private const string MechName = "mech"; + private const string MechPinMameName = "mech_pinmame"; private const string PlayfieldName = "playfield"; private const string PlayButtonName = "player"; private const string PlugName = "plug"; @@ -123,7 +124,7 @@ public IconVariant(string name, IconSize size, IconColor color) private static readonly string[] Names = { AssetLibraryName, BallRollerName, BallName, BoltName, BumperName, CalendarName, CannonName, CoilName, DropTargetBankName, DropTargetName, FlasherName, - FlipperName, GateName, GateLifterName, HitTargetName, KeyName, KickerName, LightGroupName, LightName, LoopButtonName, MechName, MechPinMameName, PlayfieldName, PlayButtonName, PlugName, + FlipperName, GateName, GateLifterName, HitTargetName, KeyName, KickerName, LightGroupName, LightName, LoopButtonName, MagnetName, MechName, MechPinMameName, PlayfieldName, PlayButtonName, PlugName, PhysicsName, PlungerName, PrimitiveName, RampName, RotatorName, RubberName, ScoreReelName, ScoreReelSingleName, SlingshotName, SpinnerName, StopButtonName, SurfaceName, SwitchNcName, SwitchNoName, TableName, TeleporterName, TriggerName, TroughName, CoilEventName, SwitchEventName, LampEventName, LampSeqName, MetalWireGuideName, @@ -191,9 +192,10 @@ private static IIconLookup[] GetLookups() { public static Texture2D Kicker(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(KickerName, size, color); public static Texture2D Light(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightName, size, color); public static Texture2D LoopButton(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LoopButtonName, size, color); - public static Texture2D LightGroup(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightGroupName, size, color); - public static Texture2D Locked(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LockedName, size, color); - public static Texture2D Mech(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechName, size, color); + public static Texture2D LightGroup(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightGroupName, size, color); + public static Texture2D Locked(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LockedName, size, color); + public static Texture2D Magnet(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MagnetName, size, color); + public static Texture2D Mech(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechName, size, color); public static Texture2D MechPinMame(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechPinMameName, size, color); public static Texture2D MetalWireGuide(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MetalWireGuideName, size, color); public static Texture2D Physics(IconSize size = IconSize.Small, IconColor color = IconColor.Gray) => Instance.GetItem(PhysicsName, size, color); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta new file mode 100644 index 000000000..bbd71f873 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a2e6af63f212a8c48993172eb0074345 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs new file mode 100644 index 000000000..a68e501f4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -0,0 +1,103 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(MagnetComponent))] + public class MagnetInspector : ItemInspector + { + private SerializedProperty _radiusProperty; + private SerializedProperty _strengthProperty; + private SerializedProperty _magnetTypeProperty; + private SerializedProperty _forceProfileProperty; + private SerializedProperty _coilRiseTimeProperty; + private SerializedProperty _coilFallTimeProperty; + private SerializedProperty _poleRadiusProperty; + private SerializedProperty _grabBallProperty; + private SerializedProperty _grabRadiusProperty; + private SerializedProperty _heightRangeProperty; + private SerializedProperty _isEnabledOnStartProperty; + private SerializedProperty _isKinematicProperty; + private SerializedProperty _drawDebugForcesProperty; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + + _radiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.Radius)); + _strengthProperty = serializedObject.FindProperty(nameof(MagnetComponent.Strength)); + _magnetTypeProperty = serializedObject.FindProperty(nameof(MagnetComponent.MagnetType)); + _forceProfileProperty = serializedObject.FindProperty(nameof(MagnetComponent.ForceProfile)); + _coilRiseTimeProperty = serializedObject.FindProperty(nameof(MagnetComponent.CoilRiseTime)); + _coilFallTimeProperty = serializedObject.FindProperty(nameof(MagnetComponent.CoilFallTime)); + _poleRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.PoleRadius)); + _grabBallProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabBall)); + _grabRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabRadius)); + _heightRangeProperty = serializedObject.FindProperty(nameof(MagnetComponent.HeightRange)); + _isEnabledOnStartProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsEnabledOnStart)); + _isKinematicProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsKinematic)); + _drawDebugForcesProperty = serializedObject.FindProperty(nameof(MagnetComponent.DrawDebugForces)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + OnPreInspectorGUI(); + + using (new EditorGUI.DisabledScope(Application.isPlaying)) { + PropertyField(_magnetTypeProperty); + } + var isSpatial = _magnetTypeProperty.enumValueIndex == (int)MagnetType.Spatial; + + PropertyField(_radiusProperty); + if (!isSpatial) { + PropertyField(_heightRangeProperty); + } + PropertyField(_strengthProperty); + if (!isSpatial) { + PropertyField(_forceProfileProperty); + } + var usesPhysicalResponse = isSpatial || _forceProfileProperty.enumValueIndex == (int)MagnetForceProfile.Physical; + if (usesPhysicalResponse) { + PropertyField(_poleRadiusProperty); + PropertyField(_coilRiseTimeProperty); + PropertyField(_coilFallTimeProperty); + } + + EditorGUILayout.Space(8f); + PropertyField(_grabBallProperty); + if (_grabBallProperty.boolValue) { + PropertyField(_grabRadiusProperty); + } + + EditorGUILayout.Space(8f); + PropertyField(_isEnabledOnStartProperty); + // kinematic registration is fixed at startup; toggling during play would silently do nothing + using (new EditorGUI.DisabledScope(Application.isPlaying)) { + PropertyField(_isKinematicProperty); + } + PropertyField(_drawDebugForcesProperty); + + base.OnInspectorGUI(); + EndEditing(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta new file mode 100644 index 000000000..26ce28b2f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9de594893816cf748a2bdeb73de60e1f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable.meta new file mode 100644 index 000000000..f21618515 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cb695f1dde41412c96022c0491db4c1c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs new file mode 100644 index 000000000..53d42887a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs @@ -0,0 +1,79 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(TurntableComponent))] + public class TurntableInspector : ItemInspector + { + private SerializedProperty _radiusProperty; + private SerializedProperty _heightRangeProperty; + private SerializedProperty _maxSpeedProperty; + private SerializedProperty _spinUpProperty; + private SerializedProperty _spinDownProperty; + private SerializedProperty _motorOnStartProperty; + private SerializedProperty _spinClockwiseProperty; + private SerializedProperty _isKinematicProperty; + private SerializedProperty _rotationTargetProperty; + private SerializedProperty _visualSpeedFactorProperty; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + + _radiusProperty = serializedObject.FindProperty(nameof(TurntableComponent.Radius)); + _heightRangeProperty = serializedObject.FindProperty(nameof(TurntableComponent.HeightRange)); + _maxSpeedProperty = serializedObject.FindProperty(nameof(TurntableComponent.MaxSpeed)); + _spinUpProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinUp)); + _spinDownProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinDown)); + _motorOnStartProperty = serializedObject.FindProperty(nameof(TurntableComponent.MotorOnStart)); + _spinClockwiseProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinClockwise)); + _isKinematicProperty = serializedObject.FindProperty(nameof(TurntableComponent.IsKinematic)); + _rotationTargetProperty = serializedObject.FindProperty(nameof(TurntableComponent.RotationTarget)); + _visualSpeedFactorProperty = serializedObject.FindProperty(nameof(TurntableComponent.VisualSpeedFactor)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + OnPreInspectorGUI(); + + PropertyField(_radiusProperty); + PropertyField(_heightRangeProperty); + PropertyField(_maxSpeedProperty); + PropertyField(_spinUpProperty); + PropertyField(_spinDownProperty); + + EditorGUILayout.Space(8f); + PropertyField(_motorOnStartProperty); + PropertyField(_spinClockwiseProperty); + // kinematic registration is fixed at startup; toggling during play would silently do nothing + using (new EditorGUI.DisabledScope(Application.isPlaying)) { + PropertyField(_isKinematicProperty); + } + PropertyField(_rotationTargetProperty); + PropertyField(_visualSpeedFactorProperty); + + base.OnInspectorGUI(); + EndEditing(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs.meta new file mode 100644 index 000000000..042d71f80 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 31f548565e49420cbe2adb41073e21cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs new file mode 100644 index 000000000..4e2bc410d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -0,0 +1,836 @@ +// 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 NativeTrees; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class MagnetPhysicsTests + { + [Test] + public void GrabbedBallSurvivesMovingHeightWindow() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 100f, + Strength = 20f, + CommandedPower = 1f, + GrabRadius = 20f, + PlanarDamping = 0.985f, + HeightRange = 25f, + IsEnabled = true + }; + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball should be grabbed"); + + // the (kinematic) magnet moves up; the held ball must not be dropped + // when the height window leaves it behind + magnet.Height = 100f; + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball should stay held"); + } + + [Test] + public void VpxCompatibleForceScalesToOneMillisecondTicks() + { + var oneTickBall = CreateBall(); + var tenTickBall = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 10f, + EffectiveStrength = 10f, + PlanarDamping = 1f + }; + + MagnetPhysics.ApplyVpxCompatibleForce(ref oneTickBall, in magnet, 1f); + for (var i = 0; i < 10; i++) { + MagnetPhysics.ApplyVpxCompatibleForce(ref tenTickBall, in magnet, 0.1f); + } + + Assert.That(tenTickBall.Velocity.x, Is.EqualTo(oneTickBall.Velocity.x).Within(1e-5f)); + Assert.That(tenTickBall.Velocity.y, Is.EqualTo(oneTickBall.Velocity.y).Within(1e-5f)); + } + + [Test] + public void PlanarDampingUsesFrameFractionExponent() + { + var ball = CreateBall(); + ball.Velocity = new float3(3f, -4f, 5f); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 0f, + PlanarDamping = 0.985f + }; + + for (var i = 0; i < 10; i++) { + MagnetPhysics.ApplyVpxCompatibleForce(ref ball, in magnet, 0.1f); + } + + Assert.That(ball.Velocity.x, Is.EqualTo(3f * 0.985f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(-4f * 0.985f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + } + + [Test] + public void VpxCompatibleForceMatchesCoreVbsAttractBall() + { + // One cvpmMagnet.AttractBall update (core.vbs), ball at (50, 0), magnet at + // origin, Size = 100, Strength = 12, resting ball: + // ratio = 50 / (1.5 * 100) = 1/3 + // force = 12 * exp(-0.6) / ((1/9) * 56) * 1.5 = 1.587634 + // VelX = (0 - 50 * force / 50) * 0.985 = -1.563819 + // Ten 1ms ticks must integrate to the same velocity within ~1% (the damping + // is applied fractionally per tick, which compounds slightly differently). + var ball = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 12f, + EffectiveStrength = 12f, + PlanarDamping = 0.985f + }; + + for (var i = 0; i < 10; i++) { + MagnetPhysics.ApplyVpxCompatibleForce(ref ball, in magnet, 0.1f); + } + + Assert.That(ball.Velocity.x, Is.EqualTo(-1.563819f).Within(0.02f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + } + + [Test] + public void VpxCompatibleForceRepelsWithNegativeStrength() + { + var ball = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = -10f, + EffectiveStrength = -10f, + PlanarDamping = 1f + }; + + MagnetPhysics.ApplyVpxCompatibleForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity.x, Is.GreaterThan(0f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + } + + [Test] + public void PhysicalForcePeaksAroundFinitePoleAndDecays() + { + var axisBall = CreateBall(); + var poleBall = CreateBall(); + var farBall = CreateBall(); + axisBall.Position = new float3(0f, 0f, 0f); + poleBall.Position = new float3(9f, 0f, 0f); + farBall.Position = new float3(60f, 0f, 0f); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 400f, + EffectiveStrength = 400f, + PoleRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalForce(ref axisBall, in magnet, 1f); + MagnetPhysics.ApplyPhysicalForce(ref poleBall, in magnet, 1f); + MagnetPhysics.ApplyPhysicalForce(ref farBall, in magnet, 1f); + + Assert.That(axisBall.Velocity.x, Is.EqualTo(0f), "lateral force is zero on the symmetry axis"); + Assert.That(poleBall.Velocity.x, Is.LessThan(0f)); + Assert.That(math.abs(poleBall.Velocity.x), Is.GreaterThan(math.abs(farBall.Velocity.x))); + } + + [Test] + public void PhysicalForceWeakensWithAirGap() + { + var nearBall = CreateBall(); + var highBall = CreateBall(); + nearBall.Position = new float3(10f, 0f, 5f); + highBall.Position = new float3(10f, 0f, 40f); + var magnet = new MagnetState { + Position = float2.zero, + Height = 0f, + HeightRange = 100f, + Radius = 100f, + Strength = 400f, + EffectiveStrength = 400f, + PoleRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalForce(ref nearBall, in magnet, 1f); + MagnetPhysics.ApplyPhysicalForce(ref highBall, in magnet, 1f); + + Assert.That(math.abs(nearBall.Velocity.x), Is.GreaterThan(math.abs(highBall.Velocity.x))); + } + + [Test] + public void PhysicalForceAttractsWithNegativeAuthoredStrength() + { + var ball = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = -400f, + EffectiveStrength = -400f, + PoleRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity.x, Is.LessThan(0f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + } + + [Test] + public void PhysicalForceHasCompactRadiusCutoff() + { + var ball = CreateBall(); + ball.Position = new float3(100f, 0f, 0f); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 400f, + EffectiveStrength = 400f, + PoleRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity, Is.EqualTo(float3.zero)); + } + + [Test] + public void SpatialRangeUsesSphericalDistance() + { + var ball = CreateBall(); + ball.Position = new float3(0f, 0f, 30f); + var playfieldMagnet = new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 40f, + HeightRange = 10f + }; + var spatialMagnet = playfieldMagnet; + spatialMagnet.MagnetType = MagnetType.Spatial; + + Assert.That(MagnetPhysics.IsBallInRange(in ball, in playfieldMagnet), Is.False); + Assert.That(MagnetPhysics.IsBallInRange(in ball, in spatialMagnet), Is.True); + } + + [Test] + public void SpatialPhysicalForcePullsInThreeDimensions() + { + var ball = CreateBall(); + ball.Position = new float3(0f, 0f, 50f); + var magnet = new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 100f, + Strength = 400f, + EffectiveStrength = 400f, + PoleRadius = 20f, + MagnetType = MagnetType.Spatial + }; + + MagnetPhysics.ApplySpatialPhysicalForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity.x, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.LessThan(0f)); + } + + [Test] + public void VpxCompatibleGrabClampsBallToMagnetCenter() + { + var ball = CreateBall(); + ball.EventPosition = new float3(49f, -2f, 10f); + ball.Velocity = new float3(3f, -4f, 5f); + ball.OldVelocity = new float3(2f, 1f, -1f); + ball.AngularMomentum = new float3(1f, 2f, 3f); + var magnet = new MagnetState { + Position = new float2(12f, -8f) + }; + + MagnetPhysics.ApplyVpxCompatibleGrab(ref ball, in magnet); + + Assert.That(ball.Position.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.Position.z, Is.EqualTo(10f)); + Assert.That(ball.EventPosition.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.Velocity, Is.EqualTo(new float3(0f, 0f, 5f))); + Assert.That(ball.OldVelocity, Is.EqualTo(new float3(0f, 0f, -1f))); + Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); + } + + [Test] + public void VpxCompatibleGrabCarriesKinematicMagnetVelocity() + { + var ball = CreateBall(); + ball.EventPosition = new float3(49f, -2f, 10f); + ball.Velocity = new float3(3f, -4f, 5f); + ball.OldVelocity = new float3(2f, 1f, -1f); + var magnet = new MagnetState { + Position = new float2(12f, -8f) + }; + var magnetVelocity = new float2(6f, -3f); + + MagnetPhysics.ApplyVpxCompatibleGrab(ref ball, in magnet, magnetVelocity); + + Assert.That(ball.Position.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.EventPosition.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.Velocity, Is.EqualTo(new float3(6f, -3f, 5f))); + Assert.That(ball.OldVelocity, Is.EqualTo(new float3(6f, -3f, -1f))); + } + + [Test] + public void PhysicalHoldPullsBallWithoutTeleporting() + { + var ball = CreateBall(); + ball.Position = new float3(10f, 0f, 10f); + ball.EventPosition = new float3(10f, 0f, 10f); + ball.AngularMomentum = new float3(0f, 1f, 0f); + var magnet = new MagnetState { + Position = float2.zero, + Strength = 20f, + EffectiveStrength = 20f, + GrabRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalHold(ref ball, in magnet, 0.1f); + + Assert.That(ball.Position.x, Is.EqualTo(10f).Within(1e-5f)); + Assert.That(ball.EventPosition.x, Is.EqualTo(10f).Within(1e-5f)); + Assert.That(ball.Velocity.x, Is.LessThan(0f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.AngularMomentum.y, Is.LessThan(1f)); + } + + [Test] + public void PhysicalHoldDoesNotAmplifyWeakCurrent() + { + var ball = CreateBall(); + ball.Position = new float3(10f, 0f, 10f); + ball.Velocity = float3.zero; + var magnet = new MagnetState { + Position = float2.zero, + EffectiveStrength = 0.1f, + PoleRadius = 20f, + GrabRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalHold(ref ball, in magnet, 0.1f); + + Assert.That(math.abs(ball.Velocity.x), Is.LessThanOrEqualTo(0.01f), "the hold cap must not exceed the effective field"); + } + + [Test] + public void PhysicalGrabRejectsFastFlyby() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(10f, 0f, 10f), + Velocity = new float3(100f, 0f, 0f) + }); + var magnet = CreatePhysicalGrabMagnet(); + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL)); + } + + [Test] + public void PhysicalGrabCapturesRetainableBall() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(10f, 0f, 10f), + Velocity = new float3(1f, 0f, 0f) + }); + var magnet = CreatePhysicalGrabMagnet(); + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + } + + [Test] + public void SpatialGrabHoldsBallWithForceNotFreeze() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(5f, -2f, 14f), + Velocity = float3.zero + }); + var magnet = new MagnetState { + Position = new float2(4f, -3f), + Height = 12f, + Radius = 100f, + Strength = 20f, + CommandedPower = 1f, + GrabRadius = 20f, + IsEnabled = true, + MagnetType = MagnetType.Spatial + }; + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + var offset = new float3(5f, -2f, 14f) - MagnetPhysics.Center3D(in magnet); + // the ball stays a live physics object; the hold is a force, not a freeze + Assert.That(ball.IsFrozen, Is.False, "a spatial magnet holds with a force, it must not freeze the ball"); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball should be grabbed"); + // the ball started at rest, so its velocity is the hold impulse, which pulls + // toward the hold point (opposes the offset) + Assert.That(math.dot(ball.Velocity, offset), Is.LessThan(0f), "the hold must pull the ball toward the hold point"); + } + + [Test] + public void SpatialHoldTracksMovingHoldPoint() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f), + Velocity = float3.zero + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + CommandedPower = 1f, + GrabRadius = 20f, + IsEnabled = true, + MagnetType = MagnetType.Spatial + }; + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + + // move the hold point; the held ball (still near the origin in the harness, + // which does not integrate displacement) is pulled toward the new point + magnet.Position = new float2(8f, -5f); + magnet.Height = 16f; + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + var offset = ball.Position - MagnetPhysics.Center3D(in magnet); + Assert.That(ball.IsFrozen, Is.False); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball stays held as the point moves"); + Assert.That(math.dot(ball.Velocity, offset), Is.LessThan(0f), "the hold pulls toward the moved hold point"); + } + + [Test] + public void SpatialGrabReleasesBallKnockedOutsideGrabRadius() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + // the ball sits inside the outer radius but well outside the grab radius, as + // if a hard hit pushed it out of the hold — it must be released, because the + // hold is a force that can be overcome, not a rigid lock + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(50f, 0f, 10f), + Velocity = new float3(60f, 0f, 0f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + CommandedPower = 1f, + GrabRadius = 20f, + IsEnabled = true, + MagnetType = MagnetType.Spatial + }; + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL), "a ball knocked outside the grab radius is released"); + Assert.That(ball.IsFrozen, Is.False); + } + + [Test] + public void PhysicalHoldDampsRelativeToKinematicMagnetVelocity() + { + var ball = CreateBall(); + ball.Position = new float3(0f, 0f, 10f); + ball.Velocity = new float3(7f, -2f, 5f); + var magnet = new MagnetState { + Position = float2.zero, + Strength = 20f, + EffectiveStrength = 20f, + GrabRadius = 20f + }; + var magnetVelocity = new float2(7f, -2f); + + MagnetPhysics.ApplyPhysicalHold(ref ball, in magnet, 0.1f, magnetVelocity); + + Assert.That(ball.Velocity.x, Is.EqualTo(7f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(-2f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + } + + [Test] + public void KinematicTransformUpdatesMagnetCenterAndHeight() + { + var magnet = new MagnetState { + Position = float2.zero, + Height = 1f + }; + var matrix = float4x4.Translate(new float3(12f, -8f, 3f)); + + MagnetPhysics.ApplyKinematicTransform(ref magnet, in matrix); + + Assert.That(magnet.Position, Is.EqualTo(new float2(12f, -8f))); + Assert.That(magnet.Height, Is.EqualTo(3f).Within(1e-5f)); + } + + [Test] + public void PlanarEjectUsesKickerAngleConvention() + { + var ball = CreateBall(); + ball.Velocity = new float3(0f, 0f, 5f); + ball.OldVelocity = new float3(0f, 0f, -1f); + ball.AngularMomentum = new float3(1f, 2f, 3f); + + MagnetPhysics.ApplyPlanarEject(ref ball, 20f, 90f); + + Assert.That(ball.Velocity.x, Is.EqualTo(20f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + Assert.That(ball.OldVelocity.x, Is.EqualTo(20f).Within(1e-5f)); + Assert.That(ball.OldVelocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.OldVelocity.z, Is.EqualTo(-1f).Within(1e-5f)); + Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); + } + + [Test] + public void SpatialEjectAddsVerticalAngle() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + MagnetType = MagnetType.Spatial + }; + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + + MagnetPhysics.EjectGrabbedBalls(17, ref magnet, ref state, 20f, 90f, 30f); + + var ball = harness.Balls[1]; + Assert.That(ball.Velocity.x, Is.EqualTo(20f * math.cos(math.radians(30f))).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(10f).Within(1e-5f)); + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL)); + } + + [Test] + public void KinematicRefreshFollowsTransformOnlyWhenKinematicAndSeeded() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.KinematicTransforms.Add(1, float4x4.Translate(new float3(120f, 80f, 30f))); + + var kinematic = new MagnetState { IsKinematic = true }; + MagnetPhysics.RefreshKinematicState(1, ref kinematic, ref state); + Assert.That(kinematic.Position, Is.EqualTo(new float2(120f, 80f))); + Assert.That(kinematic.Height, Is.EqualTo(30f).Within(1e-5f)); + + var nonKinematic = new MagnetState { Position = new float2(5f, 5f) }; + MagnetPhysics.RefreshKinematicState(1, ref nonKinematic, ref state); + Assert.That(nonKinematic.Position, Is.EqualTo(new float2(5f, 5f)), "non-kinematic magnets must not follow the transform"); + + var unseeded = new MagnetState { IsKinematic = true, Position = new float2(5f, 5f) }; + MagnetPhysics.RefreshKinematicState(99, ref unseeded, ref state); + Assert.That(unseeded.Position, Is.EqualTo(new float2(5f, 5f)), "unseeded items must keep their baked position"); + } + + [Test] + public void KinematicRefreshDerivesVelocityFromStateMaps() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.KinematicTransforms.Add(1, float4x4.Translate(new float3(120f, 80f, 30f))); + harness.KinematicVelocities.Add(1, new KinematicVelocityState { + LinearVelocity = new float3(2f, -1f, 0f), + Pivot = new float3(120f, 80f, 30f) + }); + + var magnet = new MagnetState { IsKinematic = true }; + var velocity = MagnetPhysics.RefreshKinematicState(1, ref magnet, ref state); + + Assert.That(velocity.x, Is.EqualTo(2f).Within(1e-5f)); + Assert.That(velocity.y, Is.EqualTo(-1f).Within(1e-5f)); + Assert.That(velocity.z, Is.EqualTo(0f).Within(1e-5f)); + } + + [Test] + public void KinematicRefreshSubstitutesStepVelocityDuringCatchUp() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.KinematicTransforms.Add(1, float4x4.Translate(new float3(120f, 80f, 30f))); + harness.KinematicVelocities.Add(1, new KinematicVelocityState { + LinearVelocity = new float3(1f, 0f, 0f), + StepVelocity = new float3(3f, 0f, 0f), + Pivot = new float3(120f, 80f, 30f) + }); + + var magnet = new MagnetState { IsKinematic = true }; + var velocity = MagnetPhysics.RefreshKinematicState(1, ref magnet, ref state); + + Assert.That(velocity.x, Is.EqualTo(3f).Within(1e-5f), "rate-limited catch-up must expose the step rate"); + } + + [Test] + public void PlanarEjectAddsCarrierVelocity() + { + var ball = CreateBall(); + + MagnetPhysics.ApplyPlanarEject(ref ball, 20f, 90f, new float2(5f, -3f)); + + Assert.That(ball.Velocity.x, Is.EqualTo(25f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(-3f).Within(1e-5f)); + } + + [Test] + public void SpatialCoilOffReleasesHeldBall() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f), + Velocity = new float3(3f, -2f, 5f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + MagnetType = MagnetType.Spatial, + IsEnabled = false + }; + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + + // coil off -> the disabled magnet releases its ball, which keeps its velocity + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL)); + Assert.That(ball.IsFrozen, Is.False); + Assert.That(ball.Velocity, Is.EqualTo(new float3(3f, -2f, 5f)), "a released ball keeps its live velocity"); + } + + [Test] + public void PhysicalCoilRampsAndDecaysWithTimeConstants() + { + var magnet = new MagnetState { + Strength = 100f, + CommandedPower = 1f, + RiseTime = 2f, + FallTime = 1f, + IsEnabled = true, + Profile = MagnetForceProfile.Physical + }; + + for (var i = 0; i < 20; i++) { + MagnetPhysics.AdvanceCoil(ref magnet, 0.1f); + } + var currentAfterOneRiseConstant = magnet.EffectiveCurrent; + Assert.That(currentAfterOneRiseConstant, Is.EqualTo(0.623f).Within(0.01f)); + Assert.That(magnet.EffectiveStrength, Is.EqualTo(100f * currentAfterOneRiseConstant * currentAfterOneRiseConstant).Within(1e-4f)); + + magnet.IsEnabled = false; + for (var i = 0; i < 10; i++) { + MagnetPhysics.AdvanceCoil(ref magnet, 0.1f); + } + Assert.That(magnet.EffectiveCurrent, Is.EqualTo(currentAfterOneRiseConstant * 0.386f).Within(0.01f)); + Assert.That(magnet.EffectiveStrength, Is.GreaterThan(0f), "the field decays instead of disappearing instantly"); + } + + [Test] + public void VpxCompatibleCoilResponseRemainsInstantaneous() + { + var magnet = new MagnetState { + Strength = 20f, + CommandedPower = 0.5f, + RiseTime = 10f, + FallTime = 10f, + IsEnabled = true, + Profile = MagnetForceProfile.VpxCompatible + }; + + MagnetPhysics.AdvanceCoil(ref magnet, 0.1f); + + Assert.That(magnet.EffectiveCurrent, Is.EqualTo(0.5f)); + Assert.That(magnet.EffectiveStrength, Is.EqualTo(10f)); + } + + [Test] + public void SimulationThreadCoilDeliversEveryPwmValue() + { + var enableCount = 0; + var valueCount = 0; + var lastValue = 0f; + var coil = new DeviceCoil(null, + onEnableSimulationThread: () => enableCount++, + onValueSimulationThread: value => { + valueCount++; + lastValue = value; + }); + + coil.OnCoilSimulationThread(0.25f); + coil.OnCoilSimulationThread(0.75f); + + Assert.That(enableCount, Is.EqualTo(1), "both values keep the coil enabled"); + Assert.That(valueCount, Is.EqualTo(2), "PWM changes must not be collapsed into a bool"); + Assert.That(lastValue, Is.EqualTo(0.75f)); + } + + private static BallState CreateBall() + { + return new BallState { + Id = 1, + Position = new float3(50f, 0f, 10f), + Velocity = new float3(0f, 0f, 0f) + }; + } + + private static MagnetState CreatePhysicalGrabMagnet() + { + return new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 100f, + HeightRange = 50f, + Strength = 20f, + CommandedPower = 1f, + PoleRadius = 20f, + GrabRadius = 20f, + IsEnabled = true, + Profile = MagnetForceProfile.Physical + }; + } + } + + /// + /// A minimal over hand-created containers, so + /// tests can drive the real update/state wiring instead of only the pure + /// force helpers. Containers a magnet/turntable update never touches stay + /// default. + /// + internal sealed class PhysicsStateHarness : IDisposable + { + internal NativeParallelHashMap Balls; + internal NativeParallelHashMap KinematicTransforms; + internal NativeParallelHashMap KinematicVelocities; + internal InsideOfs InsideOfs; + internal NativeQueue EventQueue; + + private PhysicsEnv _env; + private NativeOctree _octree; + private NativeColliders _colliders; + private NativeColliders _kinematicColliders; + private NativeColliders _kinematicCollidersAtIdentity; + private NativeParallelHashMap _kinematicTargetTransforms; + private NativeParallelHashMap _nonTransformableColliderTransforms; + private NativeParallelHashMap _kinematicColliderLookups; + private NativeParallelHashMap _bumperStates; + private NativeParallelHashMap _dropTargetStates; + private NativeParallelHashMap _flipperStates; + private NativeParallelHashMap _gateStates; + private NativeParallelHashMap _hitTargetStates; + private NativeParallelHashMap _kickerStates; + private NativeParallelHashMap _magnetStates; + private NativeParallelHashMap _plungerStates; + private NativeParallelHashMap _spinnerStates; + private NativeParallelHashMap _surfaceStates; + private NativeParallelHashMap _turntableStates; + private NativeParallelHashMap _triggerStates; + private NativeParallelHashSet _disabledCollisionItems; + private bool _swapBallCollisionHandling; + private NativeParallelHashMap> _elasticityLuts; + private NativeParallelHashMap> _frictionLuts; + + internal PhysicsStateHarness() + { + Balls = new NativeParallelHashMap(4, Allocator.Persistent); + KinematicTransforms = new NativeParallelHashMap(4, Allocator.Persistent); + KinematicVelocities = new NativeParallelHashMap(4, Allocator.Persistent); + InsideOfs = new InsideOfs(Allocator.Persistent); + EventQueue = new NativeQueue(Allocator.Persistent); + } + + internal PhysicsState CreateState() + { + var events = EventQueue.AsParallelWriter(); + return new PhysicsState(ref _env, ref _octree, ref _colliders, ref _kinematicColliders, + ref _kinematicCollidersAtIdentity, ref KinematicTransforms, ref _kinematicTargetTransforms, + ref _nonTransformableColliderTransforms, ref _kinematicColliderLookups, ref events, + ref InsideOfs, ref Balls, ref _bumperStates, ref _dropTargetStates, ref _flipperStates, ref _gateStates, + ref _hitTargetStates, ref _kickerStates, ref _magnetStates, ref _plungerStates, ref _spinnerStates, + ref _surfaceStates, ref _turntableStates, ref _triggerStates, ref _disabledCollisionItems, ref _swapBallCollisionHandling, + ref _elasticityLuts, ref _frictionLuts, ref KinematicVelocities); + } + + public void Dispose() + { + Balls.Dispose(); + KinematicTransforms.Dispose(); + KinematicVelocities.Dispose(); + InsideOfs.Dispose(); + EventQueue.Dispose(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs.meta new file mode 100644 index 000000000..668f598e1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91ca942c5b554c97bb532ad05c62493e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs new file mode 100644 index 000000000..9839dec86 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs @@ -0,0 +1,175 @@ +// 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 NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class TurntablePhysicsTests + { + [Test] + public void VpxCompatibleForceScalesToOneMillisecondTicks() + { + var oneTickBall = CreateBall(); + var tenTickBall = CreateBall(); + var turntable = new TurntableState { + Position = float2.zero, + Radius = 100f, + Speed = 80f + }; + + TurntablePhysics.ApplyVpxCompatibleForce(ref oneTickBall, in turntable, 1f); + for (var i = 0; i < 10; i++) { + TurntablePhysics.ApplyVpxCompatibleForce(ref tenTickBall, in turntable, 0.1f); + } + + Assert.That(tenTickBall.Velocity.x, Is.EqualTo(oneTickBall.Velocity.x).Within(1e-5f)); + Assert.That(tenTickBall.Velocity.y, Is.EqualTo(oneTickBall.Velocity.y).Within(1e-5f)); + } + + [Test] + public void PositiveSpeedAppliesVpxTangentialKick() + { + var ball = CreateBall(); + var turntable = new TurntableState { + Position = float2.zero, + Radius = 100f, + Speed = 80f + }; + + TurntablePhysics.ApplyVpxCompatibleForce(ref ball, in turntable, 1f); + + Assert.That(ball.Velocity.x, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0.5f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + } + + [Test] + public void SpeedRampsTowardMotorTarget() + { + var turntable = new TurntableState { + MaxSpeed = 100f, + TargetSpeed = 100f, + SpinUp = 100f, + SpinDown = 100f, + MotorOn = true + }; + + TurntablePhysics.UpdateSpeed(ref turntable, 1f); + + Assert.That(turntable.Speed, Is.EqualTo(1f).Within(1e-5f)); + } + + [Test] + public void MotorOffCoastsDownWithSpinDown() + { + var turntable = new TurntableState { + Speed = 50f, + MaxSpeed = 100f, + TargetSpeed = 100f, + SpinUp = 100f, + SpinDown = 4f, + MotorOn = false + }; + + TurntablePhysics.UpdateSpeed(ref turntable, 1f); + + Assert.That(turntable.Speed, Is.EqualTo(50f - 4f * 0.01f).Within(1e-5f)); + } + + [Test] + public void DirectionReversalRampsWithSpinUp() + { + // the motor drives the reversal all the way through, so the motor ramp + // applies while decelerating toward zero as well + var turntable = new TurntableState { + Speed = 100f, + MaxSpeed = 100f, + TargetSpeed = -100f, + SpinUp = 10f, + SpinDown = 4f, + MotorOn = true + }; + + TurntablePhysics.UpdateSpeed(ref turntable, 1f); + + Assert.That(turntable.Speed, Is.EqualTo(100f - 10f * 0.01f).Within(1e-5f)); + } + + [Test] + public void KinematicTransformUpdatesTurntableCenterAndHeight() + { + var turntable = new TurntableState { + Position = float2.zero, + Height = 1f + }; + var matrix = float4x4.Translate(new float3(12f, -8f, 3f)); + + TurntablePhysics.ApplyKinematicTransform(ref turntable, in matrix); + + Assert.That(turntable.Position, Is.EqualTo(new float2(12f, -8f))); + Assert.That(turntable.Height, Is.EqualTo(3f).Within(1e-5f)); + } + + [Test] + public void MovementPublishesSpeedAndAngleTogether() + { + var states = new NativeParallelHashMap(1, Allocator.Temp); + try { + states.Add(17, new TurntableState { + Speed = 42f, + RotationAngle = 123f + }); + var emitter = new Float2Emitter(); + var emitters = new Dictionary> { + { 17, emitter } + }; + var movements = new PhysicsMovements(); + + movements.ApplyTurntableMovement(ref states, emitters); + + Assert.That(emitter.Value, Is.EqualTo(new float2(42f, 123f))); + } finally { + states.Dispose(); + } + } + + private static BallState CreateBall() + { + return new BallState { + Id = 1, + Position = new float3(50f, 0f, 10f), + Velocity = new float3(0f, 0f, 5f) + }; + } + + private sealed class Float2Emitter : IAnimationValueEmitter + { + public float2 Value; + public event Action OnAnimationValueChanged; + + public void UpdateAnimationValue(float2 value) + { + Value = value; + OnAnimationValueChanged?.Invoke(value); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs.meta new file mode 100644 index 000000000..da724abaf --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51c13323bf5b4fabb3a6241ea611e7c5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef index b5e7e0a2c..19148d705 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef @@ -7,6 +7,7 @@ "Unity.Entities.Hybrid", "Unity.Mathematics", "Unity.Transforms", + "com.bartofzo.nativetrees", "VisualPinball.Engine", "VisualPinball.Engine.Test", "VisualPinball.Unity", diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index 92249149f..408e646a9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs @@ -16,11 +16,11 @@ #nullable enable -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using NLog; using UnityEngine; using Logger = NLog.Logger; @@ -101,13 +101,13 @@ public void OnStart() } } - // Ensure all dictionary writes above are visible to the simulation - // thread before it can call HandleCoilEventSimulationThread(). - Thread.MemoryBarrier(); - - if (_coilAssignments.Count > 0) { - _gamelogicEngine.OnCoilChanged += HandleCoilEvent; - } + // Ensure all dictionary writes above are visible to the simulation + // thread before it can call HandleCoilEventSimulationThread(). + Thread.MemoryBarrier(); + + if (_coilAssignments.Count > 0) { + _gamelogicEngine.OnCoilChanged += HandleCoilEvent; + } } } @@ -117,21 +117,21 @@ public void OnStart() /// /// Mapping to assign /// If it's a flasher - private void AssignCoilMapping(CoilMapping coilMapping, bool isLampCoil) - { - AssignCoilMapping(coilMapping.Id, coilMapping, isLampCoil); - if (int.TryParse(coilMapping.Id, out var id) && id.ToString() != coilMapping.Id) { - AssignCoilMapping(id.ToString(), coilMapping, isLampCoil); - } - - if (!isLampCoil && coilMapping.Device != null && coilMapping.DeviceItem == PlungerComponent.FireAndPullBackCoilId) { - // This mode's inactive state is actively pulled back, matching VPX scripts that call PullBack at table init. - _coilDevices[coilMapping.Device].Coil(coilMapping.DeviceItem)?.OnCoil(false); - } - } - - private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCoil) - { + private void AssignCoilMapping(CoilMapping coilMapping, bool isLampCoil) + { + AssignCoilMapping(coilMapping.Id, coilMapping, isLampCoil); + if (int.TryParse(coilMapping.Id, out var id) && id.ToString() != coilMapping.Id) { + AssignCoilMapping(id.ToString(), coilMapping, isLampCoil); + } + + if (!isLampCoil && coilMapping.Device != null && coilMapping.DeviceItem == PlungerComponent.FireAndPullBackCoilId) { + // This mode's inactive state is actively pulled back, matching VPX scripts that call PullBack at table init. + _coilDevices[coilMapping.Device].Coil(coilMapping.DeviceItem)?.OnCoil(false); + } + } + + private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCoil) + { if (!_coilAssignments.ContainsKey(id)) { _coilAssignments[id] = new List(); } @@ -140,19 +140,19 @@ private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCo w.DestinationDeviceItem == coilMapping.DeviceItem && w.IsDynamic) != null; - _coilAssignments[id].Add(new CoilDestConfig(coilMapping.Device, coilMapping.DeviceItem, isLampCoil, hasDynamicWire)); - CoilStatuses[id] = false; - } - - private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) - { - // always save state - CoilStatuses[coilEvent.Id] = coilEvent.IsEnabled; + _coilAssignments[id].Add(new CoilDestConfig(coilMapping.Device, coilMapping.DeviceItem, isLampCoil, hasDynamicWire)); + CoilStatuses[id] = false; + } + + private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) + { + // always save state + CoilStatuses[coilEvent.Id] = coilEvent.IsEnabled; // trigger coil if mapped - if (_coilAssignments.ContainsKey(coilEvent.Id)) { - foreach (var destConfig in _coilAssignments[coilEvent.Id]) { - if (destConfig.HasDynamicWire) { + if (_coilAssignments.ContainsKey(coilEvent.Id)) { + foreach (var destConfig in _coilAssignments[coilEvent.Id]) { + if (destConfig.HasDynamicWire) { // goes back through the wire mapping, which will decide whether it has already sent the event or not _wirePlayer!.HandleCoilEvent(coilEvent.Id, coilEvent.IsEnabled); continue; @@ -179,7 +179,9 @@ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) continue; } - coil.OnCoil(coilEvent.IsEnabled); + // pass the normalized strength; on/off coils get 0/1, PWM-integrated + // coils (e.g. magnets) get the duty-cycle value + coil.OnCoil(coilEvent.Value); } else { Logger.Error($"Cannot trigger unknown coil item \"{destConfig}\" for {coilEvent.Id}."); @@ -193,78 +195,78 @@ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) } else { Logger.Info($"Ignoring unassigned coil \"{coilEvent.Id}\"."); } - } - - internal bool HandleCoilEventSimulationThread(string id, bool isEnabled) - { - if (!_coilAssignments.ContainsKey(id)) { - return false; - } - - var dispatched = false; - foreach (var destConfig in _coilAssignments[id]) { - if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { - continue; - } - - if (!_coilDevices.ContainsKey(destConfig.Device)) { - continue; - } - - var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); - if (coil is ISimulationThreadCoil simulationThreadCoil) { - simulationThreadCoil.OnCoilSimulationThread(isEnabled); - dispatched = true; - } - } - - return dispatched; - } - - internal bool SupportsSimulationThreadDispatch(string id) - { - if (!_coilAssignments.ContainsKey(id)) { - return false; - } - - foreach (var destConfig in _coilAssignments[id]) { - if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { - continue; - } - - if (!_coilDevices.ContainsKey(destConfig.Device)) { - continue; - } - - var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); - if (coil is DeviceCoil deviceCoil && deviceCoil.SupportsSimulationThreadDispatch) { - return true; - } - } - - return false; - } - - public void OnDestroy() - { - if (_coilAssignments.Count > 0 && _gamelogicEngine != null) { - _gamelogicEngine.OnCoilChanged -= HandleCoilEvent; - } - - // Reset simulation-thread state on all device coils so a - // subsequent play session starts clean. - foreach (var destList in _coilAssignments.Values) { - foreach (var destConfig in destList) { - if (destConfig.Device == null || !_coilDevices.ContainsKey(destConfig.Device)) { - continue; - } - var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); - if (coil is DeviceCoil deviceCoil) { - deviceCoil.ResetSimulationState(); - } - } - } - } + } + + internal bool HandleCoilEventSimulationThread(string id, float value) + { + if (!_coilAssignments.ContainsKey(id)) { + return false; + } + + var dispatched = false; + foreach (var destConfig in _coilAssignments[id]) { + if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { + continue; + } + + if (!_coilDevices.ContainsKey(destConfig.Device)) { + continue; + } + + var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); + if (coil is ISimulationThreadCoil simulationThreadCoil) { + simulationThreadCoil.OnCoilSimulationThread(value); + dispatched = true; + } + } + + return dispatched; + } + + internal bool SupportsSimulationThreadDispatch(string id) + { + if (!_coilAssignments.ContainsKey(id)) { + return false; + } + + foreach (var destConfig in _coilAssignments[id]) { + if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { + continue; + } + + if (!_coilDevices.ContainsKey(destConfig.Device)) { + continue; + } + + var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); + if (coil is DeviceCoil deviceCoil && deviceCoil.SupportsSimulationThreadDispatch) { + return true; + } + } + + return false; + } + + public void OnDestroy() + { + if (_coilAssignments.Count > 0 && _gamelogicEngine != null) { + _gamelogicEngine.OnCoilChanged -= HandleCoilEvent; + } + + // Reset simulation-thread state on all device coils so a + // subsequent play session starts clean. + foreach (var destList in _coilAssignments.Values) { + foreach (var destConfig in destList) { + if (destConfig.Device == null || !_coilDevices.ContainsKey(destConfig.Device)) { + continue; + } + var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); + if (coil is DeviceCoil deviceCoil) { + deviceCoil.ResetSimulationState(); + } + } + } + } #if UNITY_EDITOR private void RefreshUI() diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta index 24c6d8592..5a69bb44c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta @@ -1,3 +1,11 @@ -fileFormatVersion: 2 +fileFormatVersion: 2 guid: 0cc90637ead34f6ab7e84e25b9179862 -timeCreated: 1745669055 \ No newline at end of file +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 5c38fa0fd9c22dd42a110b21be98044e, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs index 3c94685d4..ca0e7f83b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs @@ -22,7 +22,7 @@ namespace VisualPinball.Unity { public interface ISimulationThreadCoil { - void OnCoilSimulationThread(bool enabled); + void OnCoilSimulationThread(float value); } public class DeviceCoil : IApiCoil, ISimulationThreadCoil @@ -49,7 +49,9 @@ public class DeviceCoil : IApiCoil, ISimulationThreadCoil /// that still touch Unity objects on enable/disable, should stay on the /// normal main-thread path. /// - public bool SupportsSimulationThreadDispatch => OnEnableSimulationThread != null || OnDisableSimulationThread != null; + public bool SupportsSimulationThreadDispatch => OnEnableSimulationThread != null + || OnDisableSimulationThread != null + || OnValueSimulationThread != null; public bool IsEnabled => Volatile.Read(ref _isEnabled) != 0; public event EventHandler CoilStatusChanged; @@ -58,34 +60,50 @@ public class DeviceCoil : IApiCoil, ISimulationThreadCoil protected Action OnDisable; protected Action OnEnableSimulationThread; protected Action OnDisableSimulationThread; + protected Action OnValueSimulationThread; + + /// + /// Optional handler for the normalized coil strength in [0..1]. Set by coils + /// whose effect scales with the PWM duty cycle (e.g. magnets). Fired on every + /// call, after the enable/disable transition. + /// + protected Action OnValue; private readonly Player _player; public DeviceCoil(Player player, Action onEnable = null, Action onDisable = null, - Action onEnableSimulationThread = null, Action onDisableSimulationThread = null) + Action onEnableSimulationThread = null, Action onDisableSimulationThread = null, + Action onValue = null, Action onValueSimulationThread = null) { _player = player; OnEnable = onEnable; OnDisable = onDisable; OnEnableSimulationThread = onEnableSimulationThread; OnDisableSimulationThread = onDisableSimulationThread; + OnValue = onValue; + OnValueSimulationThread = onValueSimulationThread; } - public void OnCoil(bool enabled) + public void OnCoil(bool enabled) => OnCoil(enabled ? 1f : 0f); + + public void OnCoil(float value) { + var enabled = value > 0f; Interlocked.Exchange(ref _isEnabled, enabled ? 1 : 0); // When the simulation thread is actively handling this coil, // skip the main-thread enable/disable callbacks. The sim thread // already set the solenoid flag directly via OnCoilSimulationThread. - // We still update _isEnabled above and fire CoilStatusChanged/UI - // below, since those are main-thread-only concerns. + // We still update _isEnabled above and fire CoilStatusChanged/UI below, + // since those are main-thread-only concerns. The value callback is also + // suppressed because the simulation thread already consumed it. if (!_simThreadActive) { if (enabled) { OnEnable?.Invoke(); } else { OnDisable?.Invoke(); } + OnValue?.Invoke(value); } CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); #if UNITY_EDITOR @@ -93,23 +111,26 @@ public void OnCoil(bool enabled) #endif } - public void OnCoilSimulationThread(bool enabled) + public void OnCoilSimulationThread(float value) { + var enabled = value > 0f; // Mark this coil as sim-thread-active on first dispatch. // This suppresses the main-thread OnEnable/OnDisable callbacks. if (!_simThreadActive) { _simThreadActive = true; } - if (Interlocked.Exchange(ref _simulationEnabled, enabled ? 1 : 0) == (enabled ? 1 : 0)) { - return; + if (Interlocked.Exchange(ref _simulationEnabled, enabled ? 1 : 0) != (enabled ? 1 : 0)) { + if (enabled) { + OnEnableSimulationThread?.Invoke(); + } else { + OnDisableSimulationThread?.Invoke(); + } } - if (enabled) { - OnEnableSimulationThread?.Invoke(); - } else { - OnDisableSimulationThread?.Invoke(); - } + // Unlike enable/disable callbacks, analog/PWM values must be delivered + // even when the boolean state did not change. + OnValueSimulationThread?.Invoke(value); } public void OnChange(bool enabled) => OnCoil(enabled); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index 6234d517f..ab4a74f3f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -367,10 +367,26 @@ public readonly struct CoilEventArgs /// public readonly bool IsEnabled; + /// + /// Normalized coil strength in [0..1]. For plain on/off coils this is 0 or 1. + /// For PWM-integrated (modulated) coils — e.g. the magnet coils of WPC/SAM + /// ROMs — it is the duty-cycle strength. is + /// Value > 0. + /// + public readonly float Value; + public CoilEventArgs(string id, bool isEnabled) { Id = id; IsEnabled = isEnabled; + Value = isEnabled ? 1f : 0f; + } + + public CoilEventArgs(string id, float value) + { + Id = id; + Value = value; + IsEnabled = value > 0f; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs index a86ff0cb3..d21136ca3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs @@ -71,9 +71,13 @@ internal bool IsInsideOf(int itemId, int ballId) return _insideOfs.TryGetValue(itemId, out var bits) && bits.IsSet(GetBitIndex(ballId)); } - internal bool IsOutsideOf(int itemId, int ballId) => !IsInsideOf(itemId, ballId); - - internal int GetInsideCount(int itemId) + internal bool IsOutsideOf(int itemId, int ballId) => !IsInsideOf(itemId, ballId); + + internal int GetOrCreateBitIndex(int ballId) => GetBitIndex(ballId); + + internal bool TryGetBitIndex(int ballId, out int bitIndex) => _bitLookup.TryGetValue(ballId, out bitIndex); + + internal int GetInsideCount(int itemId) { if (!_insideOfs.TryGetValue(itemId, out var bits)) { return 0; @@ -156,7 +160,9 @@ private int GetBitIndex(int ballId) return newIndex; } - private bool TryGetBallId(int bitIndex, out int ballId) + internal bool TryGetBallIdAtBitIndex(int bitIndex, out int ballId) => TryGetBallId(bitIndex, out ballId); + + private bool TryGetBallId(int bitIndex, out int ballId) { foreach (var kvp in _bitLookup) { if (kvp.Value == bitIndex) { @@ -174,4 +180,4 @@ public void Dispose() _insideOfs.Dispose(); } } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index a078384bd..083aaaf3d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -184,7 +184,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private NudgeSystem _nudgeSystem; [NonSerialized] private VisualNudgeComponent _visualNudge; [NonSerialized] private ICollidableComponent[] _colliderComponents; - [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents; + [NonSerialized] private IKinematicTransformComponent[] _kinematicTransformComponents; [NonSerialized] private float4x4 _worldToPlayfield; [NonSerialized] private int _mainThreadManagedThreadId; [NonSerialized] private int _simulationThreadManagedThreadId = -1; @@ -703,6 +703,16 @@ internal ref KickerState KickerState(int itemId) GuardLiveStateAccess(nameof(KickerState)); return ref _ctx.KickerStates.Ref.GetValueByRef(itemId); } + internal ref MagnetState MagnetState(int itemId) + { + GuardLiveStateAccess(nameof(MagnetState)); + return ref _ctx.MagnetStates.Ref.GetValueByRef(itemId); + } + internal ref TurntableState TurntableState(int itemId) + { + GuardLiveStateAccess(nameof(TurntableState)); + return ref _ctx.TurntableStates.Ref.GetValueByRef(itemId); + } internal ref PlungerState PlungerState(int itemId) { GuardLiveStateAccess(nameof(PlungerState)); @@ -762,11 +772,13 @@ internal void Register(T item) where T : MonoBehaviour case DropTargetComponent c: _ctx.DropTargetStates.Ref[itemId] = c.CreateState(); break; case HitTargetComponent c: _ctx.HitTargetStates.Ref[itemId] = c.CreateState(); break; case KickerComponent c: _ctx.KickerStates.Ref[itemId] = c.CreateState(); break; + case MagnetComponent c: _ctx.MagnetStates.Ref[itemId] = c.CreateState(); break; case PlungerComponent c: _ctx.PlungerStates.Ref[itemId] = c.CreateState(); break; case SpinnerComponent c: _ctx.SpinnerStates.Ref[itemId] = c.CreateState(); break; case SurfaceComponent c: _ctx.SurfaceStates.Ref[itemId] = c.CreateState(); break; + case TurntableComponent c: _ctx.TurntableStates.Ref[itemId] = c.CreateState(); break; case TriggerComponent c: _ctx.TriggerStates.Ref[itemId] = c.CreateState(); break; } @@ -788,10 +800,34 @@ internal BallComponent UnregisterBall(int ballId) var b = _ctx.BallComponents[ballId]; _ctx.BallComponents.Remove(ballId); _ctx.BallStates.Ref.Remove(ballId); + ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); return b; } + /// + /// Clears a destroyed ball from all magnet grab states and emits the release + /// events. Magnet grab bitfields are keyed by InsideOfs bit indices, which get + /// recycled once frees them — so this + /// must run before the ball is removed from . + /// + private void ReleaseDestroyedBallFromMagnets(int ballId) + { + if (!_ctx.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + return; + } + using (var enumerator = _ctx.MagnetStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var magnet = ref enumerator.Current.Value; + if (magnet.GrabbedBalls.IsSet(bitIndex)) { + magnet.GrabbedBalls.SetBits(bitIndex, false); + _ctx.EventQueue.Ref.Enqueue(new EventData(Engine.Game.EventId.MagnetEventsBallReleased, enumerator.Current.Key, ballId, true)); + } + magnet.ReleasedBalls.SetBits(bitIndex, false); + } + } + } + internal void RegisterRuntimeBall(BallComponent ball) { var ballId = UnityObjectId.Get(ball.gameObject); @@ -823,12 +859,14 @@ internal BallComponent UnregisterRuntimeBall(int ballId) if (_ctx.BallStates.Ref.IsCreated) { _ctx.BallStates.Ref.Remove(ballId); } + ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); } return ball; } _ctx.BallStates.Ref.Remove(ballId); + ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); return ball; @@ -952,8 +990,10 @@ private void Awake() _ctx.ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); - _colliderComponents = GetComponentsInChildren(); - _kinematicColliderComponents = _colliderComponents.Where(c => c.IsKinematic).ToArray(); + // one hierarchy walk: every collidable is also a kinematic transform component + var kinematicTransformComponents = GetComponentsInChildren(); + _colliderComponents = kinematicTransformComponents.OfType().ToArray(); + _kinematicTransformComponents = kinematicTransformComponents.Where(c => c.IsKinematic).ToArray(); } private void Start() @@ -979,7 +1019,7 @@ private void Start() } // create static octree - Debug.Log($"Found {_colliderComponents.Length} collidable items ({_kinematicColliderComponents.Length} kinematic)."); + Debug.Log($"Found {_colliderComponents.Length} collidable items ({_colliderComponents.Count(c => c.IsKinematic)} kinematic) and {_kinematicTransformComponents.Length} kinematic transforms."); var colliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp); var kinematicColliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp, true); foreach (var colliderItem in _colliderComponents) { @@ -1004,10 +1044,10 @@ private void Start() // get kinematic collider matrices _worldToPlayfield = playfield.transform.worldToLocalMatrix; - foreach (var coll in _kinematicColliderComponents) { - var matrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); - _ctx.KinematicTransforms.Ref[coll.ItemId] = matrix; - _ctx.MainThreadKinematicCache[coll.ItemId] = matrix; + foreach (var item in _kinematicTransformComponents) { + var matrix = item.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); + _ctx.KinematicTransforms.Ref[item.ItemId] = matrix; + _ctx.MainThreadKinematicCache[item.ItemId] = matrix; } #if UNITY_EDITOR _ctx.ColliderLookups = colliders.CreateLookup(Allocator.Persistent); @@ -1047,7 +1087,7 @@ private void Start() } // Create threading helper (all context fields are now populated) - _threading = new PhysicsEngineThreading(this, _ctx, _player, _kinematicColliderComponents, _worldToPlayfield); + _threading = new PhysicsEngineThreading(this, _ctx, _player, _kinematicTransformComponents, _worldToPlayfield); // Mark as initialized for simulation thread _ctx.IsInitialized = true; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index b96032d1e..99a9662f2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -110,9 +110,11 @@ internal class PhysicsEngineContext : IDisposable public readonly LazyInit> DropTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> HitTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> KickerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); + public readonly LazyInit> MagnetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> PlungerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SpinnerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SurfaceStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); + public readonly LazyInit> TurntableStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> TriggerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> DisabledCollisionItems = new(() => new NativeParallelHashSet(0, Allocator.Persistent)); @@ -297,8 +299,8 @@ internal PhysicsState CreateState() ref KinematicCollidersAtIdentity, ref KinematicTransforms.Ref, ref KinematicTargetTransforms.Ref, ref NonTransformableColliderTransforms.Ref, ref KinematicColliderLookups, ref events, ref InsideOfs, ref BallStates.Ref, ref BumperStates.Ref, ref DropTargetStates.Ref, ref FlipperStates.Ref, ref GateStates.Ref, - ref HitTargetStates.Ref, ref KickerStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref, - ref SurfaceStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling, + ref HitTargetStates.Ref, ref KickerStates.Ref, ref MagnetStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref, + ref SurfaceStates.Ref, ref TurntableStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling, ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs, ref KinematicVelocities.Ref); } @@ -336,10 +338,12 @@ public void Dispose() } } KickerStates.Ref.Dispose(); + MagnetStates.Ref.Dispose(); PlungerStates.Ref.Dispose(); SpinnerStates.Ref.Dispose(); SurfaceStates.Ref.Dispose(); + TurntableStates.Ref.Dispose(); using (var enumerator = TriggerStates.Ref.GetEnumerator()) { while (enumerator.MoveNext()) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 2d148ecb1..1e5d83291 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -49,8 +49,8 @@ internal class PhysicsEngineThreading private readonly PhysicsEngine _physicsEngine; private readonly PhysicsEngineContext _ctx; private readonly Player _player; - private readonly ICollidableComponent[] _kinematicColliderComponents; - private readonly Dictionary _kinematicColliderComponentsByItemId; + private readonly IKinematicTransformComponent[] _kinematicTransformComponents; + private readonly Dictionary _kinematicTransformComponentsByItemId; private readonly float4x4 _worldToPlayfield; private readonly PhysicsMovements _physicsMovements = new(); private readonly List _deferredMainThreadEvents = new(); @@ -90,6 +90,7 @@ private struct HeldKinematicPose private readonly int[] _snapshotFlipperIds; private readonly int[] _snapshotBumperRingIds; private readonly int[] _snapshotBumperSkirtIds; + private readonly int[] _snapshotTurntableIds; private readonly int[] _snapshotDropTargetIds; private readonly int[] _snapshotHitTargetIds; private readonly int[] _snapshotGateIds; @@ -101,21 +102,25 @@ private struct HeldKinematicPose private bool _float2SnapshotOverflowWarningIssued; internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContext ctx, Player player, - ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield) + IKinematicTransformComponent[] kinematicTransformComponents, float4x4 worldToPlayfield) { _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); _ctx = ctx ?? throw new ArgumentNullException(nameof(ctx)); _player = player; - _kinematicColliderComponents = kinematicColliderComponents; - _kinematicColliderComponentsByItemId = new Dictionary(kinematicColliderComponents?.Length ?? 0); - if (kinematicColliderComponents != null) { - foreach (var coll in kinematicColliderComponents) { - _kinematicColliderComponentsByItemId[coll.ItemId] = coll; + _kinematicTransformComponents = kinematicTransformComponents; + _kinematicTransformComponentsByItemId = new Dictionary(kinematicTransformComponents?.Length ?? 0); + if (kinematicTransformComponents != null) { + foreach (var item in kinematicTransformComponents) { + if (_kinematicTransformComponentsByItemId.TryGetValue(item.ItemId, out var existing)) { + Logger.Warn($"[PhysicsEngine] Kinematic components {Describe(existing)} and {Describe(item)} resolve to the same item id {item.ItemId} (shared GameObject). Only one of them receives transform updates and their kinematic motion will conflict — move one to its own child GameObject."); + } + _kinematicTransformComponentsByItemId[item.ItemId] = item; } } _snapshotFlipperIds = SnapshotIds(_ctx.FlipperStates.Ref); _snapshotBumperRingIds = SnapshotIds(_ctx.BumperStates.Ref, static state => state.RingItemId != 0); _snapshotBumperSkirtIds = SnapshotIds(_ctx.BumperStates.Ref, static state => state.SkirtItemId != 0); + _snapshotTurntableIds = SnapshotIds(_ctx.TurntableStates.Ref); _snapshotDropTargetIds = SnapshotIds(_ctx.DropTargetStates.Ref, static state => state.AnimatedItemId != 0); _snapshotHitTargetIds = SnapshotIds(_ctx.HitTargetStates.Ref, static state => state.AnimatedItemId != 0); _snapshotGateIds = SnapshotIds(_ctx.GateStates.Ref); @@ -355,11 +360,14 @@ private void StageKinematicTarget(int itemId, in float4x4 matrix, ulong nowUsec) // continuous motion (incl. resolving a hold): stream toward the target, // capped per tick, so a fast collider can't skip past a ball _ctx.KinematicTargetTransforms.Ref[itemId] = matrix; - _ctx.KinematicOctreeDirty = true; + // colliderless kinematic items (magnets, turntables) don't affect the octree + if (_ctx.KinematicColliderLookups.ContainsKey(itemId)) { + _ctx.KinematicOctreeDirty = true; + } } - var coll = GetKinematicColliderComponent(itemId); - coll?.OnTransformationChanged(matrix); + var item = GetKinematicTransformComponent(itemId); + item?.OnTransformationChanged(matrix); } /// @@ -372,11 +380,12 @@ private void SnapKinematicPose(int itemId, in float4x4 matrix) _ctx.KinematicTargetTransforms.Ref[itemId] = matrix; // keep in sync, stepping skips equal poses var state = _ctx.CreateState(); - ref var colliderLookups = ref _ctx.KinematicColliderLookups.GetValueByRef(itemId); - for (var i = 0; i < colliderLookups.Length; i++) { - state.TransformKinematicColliders(colliderLookups[i], matrix); + if (_ctx.KinematicColliderLookups.TryGetValue(itemId, out var colliderLookups)) { + for (var i = 0; i < colliderLookups.Length; i++) { + state.TransformKinematicColliders(colliderLookups[i], matrix); + } + _ctx.KinematicOctreeDirty = true; } - _ctx.KinematicOctreeDirty = true; } /// @@ -462,11 +471,14 @@ private void StopKinematicVelocity(int itemId, ulong nowUsec) } } - private ICollidableComponent GetKinematicColliderComponent(int itemId) + private IKinematicTransformComponent GetKinematicTransformComponent(int itemId) { - return _kinematicColliderComponentsByItemId.TryGetValue(itemId, out var coll) ? coll : null; + return _kinematicTransformComponentsByItemId.TryGetValue(itemId, out var item) ? item : null; } + private static string Describe(IKinematicTransformComponent item) + => item is UnityEngine.MonoBehaviour mb ? $"\"{mb.name}\" ({item.GetType().Name})" : item.GetType().Name; + /// /// Copy current animation values from physics state maps into the /// given snapshot buffer. Must be allocation-free. @@ -591,9 +603,9 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) Logger.Warn($"[PhysicsEngine] Float animation snapshot capacity exceeded: {snapshot.FloatAnimationSourceCount} channels for max {SimulationState.MaxFloatAnimations}. Snapshot output is truncated."); } - // --- Float2 animations (bumper skirts) --- + // --- Float2 animations (bumper skirts and turntable speed/angle pairs) --- var float2Count = 0; - snapshot.Float2AnimationSourceCount = _snapshotBumperSkirtIds.Length; + snapshot.Float2AnimationSourceCount = _snapshotBumperSkirtIds.Length + _snapshotTurntableIds.Length; for (var i = 0; i < _snapshotBumperSkirtIds.Length && float2Count < SimulationState.MaxFloat2Animations; i++) { var itemId = _snapshotBumperSkirtIds[i]; ref var s = ref _ctx.BumperStates.Ref.GetValueByRef(itemId); @@ -601,6 +613,13 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) ItemId = itemId, Value = s.SkirtAnimation.Rotation }; } + for (var i = 0; i < _snapshotTurntableIds.Length && float2Count < SimulationState.MaxFloat2Animations; i++) { + var itemId = _snapshotTurntableIds[i]; + ref var s = ref _ctx.TurntableStates.Ref.GetValueByRef(itemId); + snapshot.Float2Animations[float2Count++] = new SimulationState.Float2Animation { + ItemId = itemId, Value = new float2(s.Speed, s.RotationAngle) + }; + } snapshot.Float2AnimationCount = float2Count; snapshot.Float2AnimationsTruncated = snapshot.Float2AnimationSourceCount > SimulationState.MaxFloat2Animations ? (byte)1 : (byte)0; if (!_float2SnapshotOverflowWarningIssued && snapshot.Float2AnimationsTruncated != 0) { @@ -752,29 +771,29 @@ internal void DrainExternalThreadCallbacks() /// internal void UpdateKinematicTransformsFromMainThread() { - if (!_ctx.UseExternalTiming || !_ctx.IsInitialized || _kinematicColliderComponents == null) return; + if (!_ctx.UseExternalTiming || !_ctx.IsInitialized || _kinematicTransformComponents == null) return; var scanStartTicks = Stopwatch.GetTimestamp(); _pendingKinematicUpdates.Clear(); _pendingKinematicStopUpdates.Clear(); - foreach (var coll in _kinematicColliderComponents) { - var currMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); + foreach (var item in _kinematicTransformComponents) { + var currMatrix = item.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); // Check against main-thread cache - if (_ctx.MainThreadKinematicCache.TryGetValue(coll.ItemId, out var lastMatrix) && lastMatrix.Equals(currMatrix)) { + if (_ctx.MainThreadKinematicCache.TryGetValue(item.ItemId, out var lastMatrix) && lastMatrix.Equals(currMatrix)) { // unchanged — if it moved last frame, it just stopped, so stage a velocity reset - if (_movedKinematicItems.Remove(coll.ItemId)) { - _pendingKinematicStopUpdates.Add(coll.ItemId); + if (_movedKinematicItems.Remove(item.ItemId)) { + _pendingKinematicStopUpdates.Add(item.ItemId); } continue; } // Transform changed — update cache - _ctx.MainThreadKinematicCache[coll.ItemId] = currMatrix; - _movedKinematicItems.Add(coll.ItemId); - _pendingKinematicUpdates.Add(new KeyValuePair(coll.ItemId, currMatrix)); + _ctx.MainThreadKinematicCache[item.ItemId] = currMatrix; + _movedKinematicItems.Add(item.ItemId); + _pendingKinematicUpdates.Add(new KeyValuePair(item.ItemId, currMatrix)); } if (_pendingKinematicUpdates.Count == 0 && _pendingKinematicStopUpdates.Count == 0) { @@ -816,23 +835,23 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) // check for updated kinematic transforms; compare against the last staged // pose — held or target — not the stepped current pose, which may lag // behind while catching up - foreach (var coll in _kinematicColliderComponents) { + foreach (var item in _kinematicTransformComponents) { float4x4 lastTransformationMatrix; - if (_heldIsolatedPoses.TryGetValue(coll.ItemId, out var held)) { + if (_heldIsolatedPoses.TryGetValue(item.ItemId, out var held)) { lastTransformationMatrix = held.Pose; - } else if (!_ctx.KinematicTargetTransforms.Ref.TryGetValue(coll.ItemId, out lastTransformationMatrix)) { - lastTransformationMatrix = _ctx.KinematicTransforms.Ref[coll.ItemId]; + } else if (!_ctx.KinematicTargetTransforms.Ref.TryGetValue(item.ItemId, out lastTransformationMatrix)) { + lastTransformationMatrix = _ctx.KinematicTransforms.Ref[item.ItemId]; } - var currTransformationMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); + var currTransformationMatrix = item.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); if (lastTransformationMatrix.Equals(currTransformationMatrix)) { // unchanged — if it moved last frame, it just stopped, so zero its velocity - if (_movedKinematicItems.Remove(coll.ItemId)) { - StopKinematicVelocity(coll.ItemId, _ctx.PhysicsEnv.CurPhysicsFrameTime); + if (_movedKinematicItems.Remove(item.ItemId)) { + StopKinematicVelocity(item.ItemId, _ctx.PhysicsEnv.CurPhysicsFrameTime); } continue; } - _movedKinematicItems.Add(coll.ItemId); - StageKinematicTarget(coll.ItemId, in currTransformationMatrix, _ctx.PhysicsEnv.CurPhysicsFrameTime); + _movedKinematicItems.Add(item.ItemId); + StageKinematicTarget(item.ItemId, in currTransformationMatrix, _ctx.PhysicsEnv.CurPhysicsFrameTime); } ProcessHeldKinematicPoses(_ctx.PhysicsEnv.CurPhysicsFrameTime); @@ -899,6 +918,7 @@ private void ApplyAllMovements(ref PhysicsState state) _physicsMovements.ApplyPlungerMovement(ref _ctx.PlungerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplySpinnerMovement(ref _ctx.SpinnerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplyTriggerMovement(ref _ctx.TriggerStates.Ref, _ctx.FloatAnimatedComponents); + _physicsMovements.ApplyTurntableMovement(ref _ctx.TurntableStates.Ref, _ctx.Float2AnimatedComponents); _physicsEngine.ApplyVisualNudge(_ctx.PhysicsEnv.Nudge.CabinetOffset); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs index a2aacea8c..7f8c83ecb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs @@ -177,11 +177,12 @@ internal static void StepKinematics(ref PhysicsState state) state.KinematicVelocities[itemId] = velocity; } - ref var colliderLookups = ref state.KinematicColliderLookups.GetValueByRef(itemId); - for (var i = 0; i < colliderLookups.Length; i++) { - state.TransformKinematicColliders(colliderLookups[i], current); - } - } + if (state.KinematicColliderLookups.TryGetValue(itemId, out var colliderLookups)) { + for (var i = 0; i < colliderLookups.Length; i++) { + state.TransformKinematicColliders(colliderLookups[i], current); + } + } + } PerfMarkerTransform.End(); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs index 6037d0861..01d94a963 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs @@ -129,7 +129,7 @@ internal void ApplySpinnerMovement(ref NativeParallelHashMap } } - internal void ApplyTriggerMovement(ref NativeParallelHashMap triggerStates, + internal void ApplyTriggerMovement(ref NativeParallelHashMap triggerStates, Dictionary> floatAnimatedComponent) { using var enumerator = triggerStates.GetEnumerator(); @@ -141,6 +141,17 @@ internal void ApplyTriggerMovement(ref NativeParallelHashMap var component = floatAnimatedComponent[enumerator.Current.Key]; component.UpdateAnimationValue(triggerState.Movement.HeightOffset); } - } - } -} + } + + internal void ApplyTurntableMovement(ref NativeParallelHashMap turntableStates, + Dictionary> float2AnimatedComponent) + { + using var enumerator = turntableStates.GetEnumerator(); + while (enumerator.MoveNext()) { + ref var turntableState = ref enumerator.Current.Value; + var component = float2AnimatedComponent[enumerator.Current.Key]; + component.UpdateAnimationValue(new float2(turntableState.Speed, turntableState.RotationAngle)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index 90f7437b3..43776bb74 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -138,9 +138,11 @@ internal struct PhysicsState internal NativeParallelHashMap GateStates; internal NativeParallelHashMap HitTargetStates; internal NativeParallelHashMap KickerStates; + internal NativeParallelHashMap MagnetStates; internal NativeParallelHashMap PlungerStates; internal NativeParallelHashMap SpinnerStates; internal NativeParallelHashMap SurfaceStates; + internal NativeParallelHashMap TurntableStates; internal NativeParallelHashMap TriggerStates; internal NativeParallelHashSet DisabledCollisionItems; @@ -157,8 +159,10 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native ref NativeParallelHashMap bumperStates, ref NativeParallelHashMap dropTargetStates, ref NativeParallelHashMap flipperStates, ref NativeParallelHashMap gateStates, ref NativeParallelHashMap hitTargetStates, ref NativeParallelHashMap kickerStates, + ref NativeParallelHashMap magnetStates, ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, - ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap triggerStates, + ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap turntableStates, + ref NativeParallelHashMap triggerStates, ref NativeParallelHashSet disabledCollisionItems, ref bool swapBallCollisionHandling, ref NativeParallelHashMap> elasticityOverVelocityLUTs, ref NativeParallelHashMap> frictionOverVelocityLUTs, @@ -182,9 +186,11 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native GateStates = gateStates; HitTargetStates = hitTargetStates; KickerStates = kickerStates; + MagnetStates = magnetStates; PlungerStates = plungerStates; SpinnerStates = spinnerStates; SurfaceStates = surfaceStates; + TurntableStates = turntableStates; TriggerStates = triggerStates; DisabledCollisionItems = disabledCollisionItems; SwapBallCollisionHandling = swapBallCollisionHandling; @@ -303,29 +309,52 @@ internal float3 GetKinematicSurfaceVelocity(ref NativeColliders colliders, int c return float3.zero; } var itemId = colliders.GetItemId(colliderId); - if (!KinematicVelocities.TryGetValue(itemId, out var velocity)) { + if (!TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) { return float3.zero; } + if (colliders.IsTransformed(colliderId)) { + return linear + math.cross(angular, position - pivot); + } + ref var matrix = ref KinematicTransforms.GetValueByRef(itemId); + var velocityAtPosition = linear + math.cross(angular, matrix.MultiplyPoint(position) - pivot); + return math.inverse(matrix).MultiplyVector(velocityAtPosition); + } + + /// + /// Velocity of a kinematic item at a given playfield-space point, + /// including the step-rate substitution while the pose catch-up is + /// rate-limited. Zero for unknown or resting items. Used by force + /// fields (magnets) whose held balls move with the item. + /// + internal float3 GetKinematicVelocityAt(int itemId, in float3 position) + { + if (!TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) { + return float3.zero; + } + return linear + math.cross(angular, position - pivot); + } + + private bool TryGetKinematicVelocity(int itemId, out float3 linear, out float3 angular, out float3 pivot) + { + if (!KinematicVelocities.TryGetValue(itemId, out var velocity)) { + linear = float3.zero; + angular = float3.zero; + pivot = float3.zero; + return false; + } + // while the pose catch-up is rate-limited (clamped stepping), the actual // step rate is the honest surface velocity and can exceed the derived one - var linear = velocity.LinearVelocity; - var angular = velocity.AngularVelocity; + linear = velocity.LinearVelocity; + angular = velocity.AngularVelocity; + pivot = velocity.Pivot; if (math.lengthsq(velocity.StepVelocity) > math.lengthsq(linear) || math.lengthsq(velocity.StepAngularVelocity) > math.lengthsq(angular)) { linear = velocity.StepVelocity; angular = velocity.StepAngularVelocity; } - if (math.lengthsq(linear) < 1e-8f && math.lengthsq(angular) < 1e-8f) { - return float3.zero; - } - - if (colliders.IsTransformed(colliderId)) { - return linear + math.cross(angular, position - velocity.Pivot); - } - ref var matrix = ref KinematicTransforms.GetValueByRef(itemId); - var velocityAtPosition = linear + math.cross(angular, matrix.MultiplyPoint(position) - velocity.Pivot); - return math.inverse(matrix).MultiplyVector(velocityAtPosition); + return math.lengthsq(linear) >= 1e-8f || math.lengthsq(angular) >= 1e-8f; } /// @@ -441,4 +470,4 @@ private bool IsInactiveDropTarget(ref NativeColliders colliders, int colliderId) #endregion } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index ad5e5f469..cd4e96723 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -141,14 +141,28 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ } } // spinners - using (var enumerator = state.SpinnerStates.GetEnumerator()) { - while (enumerator.MoveNext()) { - ref var spinnerState = ref enumerator.Current.Value; - SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static); - } - } - - #endregion + using (var enumerator = state.SpinnerStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var spinnerState = ref enumerator.Current.Value; + SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static); + } + } + // magnets + using (var enumerator = state.MagnetStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var magnetState = ref enumerator.Current.Value; + MagnetPhysics.Update(enumerator.Current.Key, ref magnetState, ref state, physicsDiffTime); + } + } + // turntables + using (var enumerator = state.TurntableStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var turntableState = ref enumerator.Current.Value; + TurntablePhysics.Update(enumerator.Current.Key, ref turntableState, ref state, physicsDiffTime); + } + } + + #endregion // primary physics loop cycle.Simulate(ref state, ref overlappingColliders, ref kinematicOctree, ref ballOctree, physicsDiffTime); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index b408eef89..5f9446464 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -89,9 +89,10 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private readonly Dictionary _hittables = new(); private readonly Dictionary _rotatables = new(); private readonly Dictionary _collidables = new(); - private readonly Dictionary _spinnables = new(); - private readonly Dictionary _slingshots = new(); - private readonly Dictionary _droppables = new(); + private readonly Dictionary _spinnables = new(); + private readonly Dictionary _slingshots = new(); + private readonly Dictionary _droppables = new(); + private readonly Dictionary _magnets = new(); internal IEnumerable ColliderGenerators => _colliderGenerators; @@ -182,10 +183,10 @@ internal void DispatchInputAction(string actionName, bool isPressed) CabinetInputActionChanged?.Invoke(actionName, isPressed); } - internal bool DispatchCoilSimulationThread(string coilId, bool isEnabled) - { - return _coilPlayer.HandleCoilEventSimulationThread(coilId, isEnabled); - } + internal bool DispatchCoilSimulationThread(string coilId, float value) + { + return _coilPlayer.HandleCoilEventSimulationThread(coilId, value); + } public bool SupportsSimulationThreadCoilDispatch(string coilId) { @@ -324,11 +325,14 @@ public void Register(TApi api, MonoBehaviour component) where TApi : IApi if (api is IApiSlingshot slingshot) { _slingshots[itemId] = slingshot; } - if (api is IApiDroppable droppable) { - _droppables[itemId] = droppable; - } - if (api is IApiSpinnable spinnable) { - _spinnables[itemId] = spinnable; + if (api is IApiDroppable droppable) { + _droppables[itemId] = droppable; + } + if (api is IApiMagnetEvents magnetEvents) { + _magnets[itemId] = magnetEvents; + } + if (api is IApiSpinnable spinnable) { + _spinnables[itemId] = spinnable; } if (api is IApiSwitchDevice switchDevice) { if (component is ISwitchDeviceComponent switchDeviceComponent) { @@ -434,12 +438,28 @@ public void OnEvent(in EventData eventData) _droppables[eventData.ItemId].OnDropStatusChanged(true, eventData.BallId); break; - case EventId.TargetEventsRaised: - _droppables[eventData.ItemId].OnDropStatusChanged(false, eventData.BallId); - break; - - default: - throw new InvalidOperationException($"Unknown event {eventData.EventId} for entity {eventData.ItemId}"); + case EventId.TargetEventsRaised: + _droppables[eventData.ItemId].OnDropStatusChanged(false, eventData.BallId); + break; + + case EventId.MagnetEventsBallEntered: + _magnets[eventData.ItemId].OnMagnetBallEntered(eventData.BallId); + break; + + case EventId.MagnetEventsBallExited: + _magnets[eventData.ItemId].OnMagnetBallExited(eventData.BallId); + break; + + case EventId.MagnetEventsBallGrabbed: + _magnets[eventData.ItemId].OnMagnetBallGrabbed(eventData.BallId); + break; + + case EventId.MagnetEventsBallReleased: + _magnets[eventData.ItemId].OnMagnetBallReleased(eventData.BallId); + break; + + default: + throw new InvalidOperationException($"Unknown event {eventData.EventId} for entity {eventData.ItemId}"); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs index 99acb1e4d..f44bb0405 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs @@ -84,6 +84,8 @@ public PackagedRefs(Transform tableRoot) foreach (var dependencyType in _referencedDependencyTypes) { (Activator.CreateInstance(dependencyType) as IReferencedDependency)!.RegisterTypes(this); } + // native Unity types that components reference directly + Add(typeof(Transform), "Transform"); // add third parties } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Engine.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Engine.meta deleted file mode 100644 index 81be5fb36..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Engine.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 3207b93bf2061e84698041c3ef2346fe -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 0d4da20c4..f2af18f85 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -48,9 +48,9 @@ public class SimulationState : IDisposable internal const int MaxFloatAnimations = 128; /// - /// Maximum number of float2-animated items (bumper skirts) + /// Maximum number of float2-animated items (bumper skirts and turntables) /// - internal const int MaxFloat2Animations = 16; + internal const int MaxFloat2Animations = 32; #region Animation Snapshot Structures diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 0fd4bc8d1..077849294 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -50,7 +50,7 @@ public class SimulationThread : IDisposable private readonly IGamelogicPerformanceStats _gamelogicPerformanceStats; private readonly IGamelogicLatencyStats _gamelogicLatencyStats; private readonly IGamelogicInputDispatcher _inputDispatcher; - private readonly Action _simulationCoilDispatcher; + private readonly Action _simulationCoilDispatcher; private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; private readonly System.Random _nudgeRandom = new System.Random(); @@ -115,7 +115,7 @@ public PendingSwitchEvent(string switchId, bool isClosed) #region Constructor public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine, - Action simulationCoilDispatcher) + Action simulationCoilDispatcher) { _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); _gamelogicEngine = gamelogicEngine; @@ -568,7 +568,7 @@ private void ProcessGamelogicOutputs() var processed = 0; while (processed < MaxCoilOutputsPerTick && _coilOutputFeed.TryDequeueCoilEvent(out var coilEvent)) { _lastCoilDispatchUsec = GetTimestampUsec(); - _simulationCoilDispatcher(coilEvent.Id, coilEvent.IsEnabled); + _simulationCoilDispatcher(coilEvent.Id, coilEvent.Value); processed++; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta index db138d460..8197947e8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 4dafa5d92cb47604eb814669e917b4cf \ No newline at end of file +guid: 4dafa5d92cb47604eb814669e917b4cf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: bd2796541fa61b948887270d6239e265, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 7cdde2bb0..f92326a46 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -377,7 +377,7 @@ public void StartSimulation() // Create simulation thread _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine, player != null - ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) + ? new Action((coilId, value) => player.DispatchCoilSimulationThread(coilId, value)) : null); ConfigureTiltBobRouting(); _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta index 427138b97..6d0fd2338 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 8619aa4eadc3aed469744e0a0db4bf30 \ No newline at end of file +guid: 8619aa4eadc3aed469744e0a0db4bf30 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: bd2796541fa61b948887270d6239e265, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs index 843272072..56437ee15 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs @@ -16,10 +16,10 @@ using System; using System.Linq; -using Unity.Mathematics; -using UnityEngine; -using VisualPinball.Engine.VPT.Bumper; -using VisualPinball.Unity.Collections; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.VPT.Bumper; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -68,47 +68,50 @@ public BumperApi(GameObject go, Player player, PhysicsEngine physicsEngine) : ba void IApiSwitch.AddWireDest(WireDestConfig wireConfig) => AddWireDest(wireConfig); void IApiSwitch.RemoveWireDest(string destId) => RemoveWireDest(destId); - void IApiCoil.OnCoil(bool enabled) - { - if (enabled) { - var bumperPos = (float3)MainComponent.Position; - var force = ColliderComponent.Force; - var switchColliderId = _switchColliderId; - var physicsMaterialData = ColliderComponent.GetPhysicsMaterialData(); - PhysicsEngine.MutateState((ref PhysicsState state) => { - ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); - bumperState.RingAnimation.IsHit = true; - - var idsOfBallsInColl = state.InsideOfs.GetIdsOfBallsInsideItem(ItemId); - foreach (var ballId in idsOfBallsInColl) { - if (!state.Balls.ContainsKey(ballId)) { - continue; - } - - ref var ballState = ref state.Balls.GetValueByRef(ballId); - var bumpDirection = ballState.Position - bumperPos; - bumpDirection.z = 0f; - bumpDirection = math.normalize(bumpDirection); - var collEvent = new CollisionEventData { - HitTime = 0f, - HitNormal = bumpDirection, - HitVelocity = new float2(bumpDirection.x, bumpDirection.y) * force, - HitDistance = 0f, - HitFlag = false, - HitOrgNormalVelocity = math.dot(bumpDirection, math.normalize(ballState.Velocity)), - IsContact = true, - ColliderId = switchColliderId, - IsKinematic = false, - BallId = ballId - }; - BumperCollider.PushBallAway(ref ballState, in bumperState.Static, ref collEvent, in physicsMaterialData, ref state); - } - }); - } - - CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); + void IApiCoil.OnCoil(bool enabled) + { + if (enabled) { + var bumperPos = (float3)MainComponent.Position; + var force = ColliderComponent.Force; + var switchColliderId = _switchColliderId; + var physicsMaterialData = ColliderComponent.GetPhysicsMaterialData(); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.RingAnimation.IsHit = true; + + var idsOfBallsInColl = state.InsideOfs.GetIdsOfBallsInsideItem(ItemId); + foreach (var ballId in idsOfBallsInColl) { + if (!state.Balls.ContainsKey(ballId)) { + continue; + } + + ref var ballState = ref state.Balls.GetValueByRef(ballId); + var bumpDirection = ballState.Position - bumperPos; + bumpDirection.z = 0f; + bumpDirection = math.normalize(bumpDirection); + var collEvent = new CollisionEventData { + HitTime = 0f, + HitNormal = bumpDirection, + HitVelocity = new float2(bumpDirection.x, bumpDirection.y) * force, + HitDistance = 0f, + HitFlag = false, + HitOrgNormalVelocity = math.dot(bumpDirection, math.normalize(ballState.Velocity)), + IsContact = true, + ColliderId = switchColliderId, + IsKinematic = false, + BallId = ballId + }; + BumperCollider.PushBallAway(ref ballState, in bumperState.Static, ref collEvent, in physicsMaterialData, ref state); + } + }); + } + + CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); } + // a bumper fires on energize; the PWM magnitude is not meaningful for it + void IApiCoil.OnCoil(float value) => (this as IApiCoil).OnCoil(value > 0f); + void IApiWireDest.OnChange(bool enabled) => (this as IApiCoil).OnCoil(enabled); internal override void AddWireDest(WireDestConfig wireConfig) @@ -123,16 +126,16 @@ internal override void RemoveWireDest(string destId) UpdateBumperWireState(); } - private void UpdateBumperWireState() - { - string coilId = MainComponent.AvailableCoils.FirstOrDefault().Id; - BumperComponent bumperComponent = MainComponent; - var isSwitchWiredToCoil = HasWireDest(bumperComponent, coilId); - PhysicsEngine.MutateState((ref PhysicsState state) => { - ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); - bumperState.IsSwitchWiredToCoil = isSwitchWiredToCoil; - }); - } + private void UpdateBumperWireState() + { + string coilId = MainComponent.AvailableCoils.FirstOrDefault().Id; + BumperComponent bumperComponent = MainComponent; + var isSwitchWiredToCoil = HasWireDest(bumperComponent, coilId); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.IsSwitchWiredToCoil = isSwitchWiredToCoil; + }); + } #endregion @@ -174,19 +177,19 @@ void IApiHittable.OnHit(int ballId, bool isUnHit) Switch?.Invoke(this, new SwitchEventArgs(false, ballId)); OnSwitch(false); } - } else { - Hit?.Invoke(this, new HitEventArgs(ballId)); - PhysicsEngine.MutateState((ref PhysicsState state) => { - ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); - bumperState.SkirtAnimation.HitEvent = true; - bumperState.RingAnimation.IsHit = true; - if (state.Balls.ContainsKey(ballId)) { - bumperState.SkirtAnimation.BallPosition = state.Balls.GetValueByRef(ballId).Position; - } - }); - Switch?.Invoke(this, new SwitchEventArgs(true, ballId)); - OnSwitch(true); - } + } else { + Hit?.Invoke(this, new HitEventArgs(ballId)); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.SkirtAnimation.HitEvent = true; + bumperState.RingAnimation.IsHit = true; + if (state.Balls.ContainsKey(ballId)) { + bumperState.SkirtAnimation.BallPosition = state.Balls.GetValueByRef(ballId).Position; + } + }); + Switch?.Invoke(this, new SwitchEventArgs(true, ballId)); + OnSwitch(true); + } } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs index 4ae450528..7b25f6ed8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs @@ -828,12 +828,11 @@ private static void DrawAabb(Aabb aabb, bool isSelected) #endregion - void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) - => InstantiateColliderApi(player, physicsEngine).CreateColliders(ref colliders, translateWithinPlayfieldMatrix, margin); - - int ICollidableComponent.ItemId => UnityObjectId.Get(MainComponent.gameObject); - bool ICollidableComponent.IsCollidable => isActiveAndEnabled; - } + void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) + => InstantiateColliderApi(player, physicsEngine).CreateColliders(ref colliders, translateWithinPlayfieldMatrix, margin); + + bool ICollidableComponent.IsCollidable => isActiveAndEnabled; + } internal static class ColliderColor { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs index db5f9c759..077258f1e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs @@ -40,7 +40,6 @@ public HitEventArgs(int ballId) } } - public readonly struct SwitchEventArgs { public readonly bool IsEnabled; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs index 171390285..5cbaf0d6d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs @@ -222,6 +222,15 @@ public interface IApiCoil : IApiWireDest /// /// Whether the coil was enabled (power) or disabled (no power). void OnCoil(bool enabled); + + /// + /// The coil strength changed. is normalized to [0..1]: + /// 0 or 1 for plain on/off coils, or the duty-cycle strength for PWM-integrated + /// (modulated) coils. Consumers that only care about on/off can forward this to + /// as value > 0. + /// + void OnCoil(float value); + event EventHandler CoilStatusChanged; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs index 1e2a87932..ddc45c851 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs @@ -18,49 +18,50 @@ namespace VisualPinball.Unity { - public interface ICollidableComponent + public interface IKinematicTransformComponent { - /// - /// Generates the colliders. - /// - /// - /// - /// - /// - /// - internal void GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, - float4x4 translateWithinPlayfieldMatrix, float margin); - /// /// The unique identifier of the main item. /// public int ItemId { get; } /// - /// Returns whether this specific item is set to collidable, i.e. whether can it ever be - /// collided with during gameplay. - /// - internal bool IsCollidable { get; } - - /// - /// If set, this collider can be transformed during gameplay. + /// If set, this item can be transformed during gameplay. /// public bool IsKinematic { get; } /// - /// The translation matrix, that will be applied in reverse to the ball - /// for hit testing and collision. + /// The item's local-to-playfield matrix in VPX coordinates. /// /// The playfield's worldToLocal matrix. /// public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield); /// - /// Executed on kinematic colliders, when the transformation has changed. This allows updating data if necessary, - /// for example the kicker center, which is relevant when spawning balls. + /// Executed on kinematic items when the transformation has changed. /// /// public void OnTransformationChanged(float4x4 currTransformationMatrix); + } + + public interface ICollidableComponent : IKinematicTransformComponent + { + /// + /// Generates the colliders. + /// + /// + /// + /// + /// + /// + internal void GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, + float4x4 translateWithinPlayfieldMatrix, float margin); + + /// + /// Returns whether this specific item is set to collidable, i.e. whether can it ever be + /// collided with during gameplay. + /// + internal bool IsCollidable { get; } public bool CollidersDirty { set; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet.meta new file mode 100644 index 000000000..97e504578 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 89b9f66ef6f0474887d01d1f8ef5f814 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs new file mode 100644 index 000000000..f0ae2fe09 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -0,0 +1,230 @@ +// 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; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceDest, IApiMagnetEvents + { + private readonly MagnetComponent _component; + private readonly Player _player; + private readonly PhysicsEngine _physicsEngine; + private readonly int _itemId; + + private DeviceCoil _magnetCoil; + private DeviceSwitch _ballHeldSwitch; + // written by the simulation thread's coil dispatch, read by the main thread + private volatile bool _isEnabled; + private readonly HashSet _heldBalls = new(); + + public event EventHandler Init; + public event EventHandler BallEntered; + public event EventHandler BallExited; + public event EventHandler BallGrabbed; + public event EventHandler BallReleased; + + internal MagnetApi(GameObject go, Player player, PhysicsEngine physicsEngine) + { + _component = go.GetComponentInChildren(); + _player = player; + _physicsEngine = physicsEngine; + _itemId = UnityObjectId.Get(_component.gameObject); + _isEnabled = _component.IsEnabledOnStart; + + _magnetCoil = new DeviceCoil(_player, onValue: OnCoilValue, onValueSimulationThread: OnCoilValueSimulationThread); + _ballHeldSwitch = new DeviceSwitch(MagnetComponent.BallHeldSwitchItem, false, SwitchDefault.NormallyOpen, _player, _physicsEngine); + } + + public bool IsEnabled { + get => _isEnabled; + set => SetEnabled(value); + } + + public float Strength { + get => _component.Strength; + set { + _component.Strength = value; + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.Strength = value; + } + }); + } + } + + public float Radius { + get => _component.Radius; + set { + var radius = math.max(0f, value); + _component.Radius = radius; + if (!_physicsEngine) { + return; + } + var radiusVpx = MagnetComponent.MillimetersToVpx(radius); + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.Radius = radiusVpx; + } + }); + } + } + + public void ReleaseBall() + { + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.MagnetStates.ContainsKey(_itemId)) { + return; + } + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + MagnetPhysics.ReleaseGrabbedBalls(_itemId, ref magnet, ref state, true); + }); + } + + public void Eject(float speed, float angleDeg, float verticalAngleDeg = 0f) + { + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.MagnetStates.ContainsKey(_itemId)) { + return; + } + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + MagnetPhysics.EjectGrabbedBalls(_itemId, ref magnet, ref state, speed, angleDeg, verticalAngleDeg); + }); + } + + void IApi.OnInit(BallManager ballManager) + { + SetEnabled(_component.IsEnabledOnStart); + Init?.Invoke(this, EventArgs.Empty); + } + + void IApi.OnDestroy() + { + } + + IApiCoil IApiCoilDevice.Coil(string deviceItem) => Coil(deviceItem); + IApiWireDest IApiWireDeviceDest.Wire(string deviceItem) => Coil(deviceItem); + IApiSwitch IApiSwitchDevice.Switch(string deviceItem) => Switch(deviceItem); + + private IApiCoil Coil(string deviceItem) + { + return deviceItem switch { + MagnetComponent.MagnetCoilItem => _magnetCoil, + _ => throw new ArgumentException($"Unknown magnet coil \"{deviceItem}\". Valid name is \"{MagnetComponent.MagnetCoilItem}\".") + }; + } + + private IApiSwitch Switch(string deviceItem) + { + return deviceItem switch { + MagnetComponent.BallHeldSwitchItem => _ballHeldSwitch, + _ => throw new ArgumentException($"Unknown magnet switch \"{deviceItem}\". Valid name is \"{MagnetComponent.BallHeldSwitchItem}\".") + }; + } + + /// + /// Sets the normalized electrical command in [0..1] (e.g. Iron Man's ROM + /// pulses the magnet coils). Physical profiles integrate this command into + /// effective current; VPX Compatible applies it immediately. + /// + private void OnCoilValue(float value) + { + if (!_physicsEngine) { + return; + } + var normalizedValue = math.saturate(value); + var enabled = normalizedValue > 0f; + _isEnabled = enabled; + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.IsEnabled = enabled; + magnet.CommandedPower = normalizedValue; + } + }); + } + + private void OnCoilValueSimulationThread(float value) + { + var normalizedValue = math.saturate(value); + var enabled = normalizedValue > 0f; + _isEnabled = enabled; + if (!_physicsEngine) { + return; + } + ref var magnet = ref _physicsEngine.MagnetState(_itemId); + magnet.IsEnabled = enabled; + magnet.CommandedPower = normalizedValue; + } + + private void SetEnabled(bool enabled) + { + _isEnabled = enabled; + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.IsEnabled = enabled; + magnet.CommandedPower = enabled ? 1f : 0f; + } + }); + } + + void IApiMagnetEvents.OnMagnetBallEntered(int ballId) => BallEntered?.Invoke(this, new HitEventArgs(ballId)); + void IApiMagnetEvents.OnMagnetBallExited(int ballId) => BallExited?.Invoke(this, new HitEventArgs(ballId)); + + void IApiMagnetEvents.OnMagnetBallGrabbed(int ballId) + { + _heldBalls.Add(ballId); + _ballHeldSwitch.SetSwitch(true); + BallGrabbed?.Invoke(this, new HitEventArgs(ballId)); + } + + void IApiMagnetEvents.OnMagnetBallReleased(int ballId) + { + _heldBalls.Remove(ballId); + if (_heldBalls.Count == 0) { + _ballHeldSwitch.SetSwitch(false); + } + BallReleased?.Invoke(this, new HitEventArgs(ballId)); + } + } + + internal interface IApiMagnetEvents + { + void OnMagnetBallEntered(int ballId); + void OnMagnetBallExited(int ballId); + void OnMagnetBallGrabbed(int ballId); + void OnMagnetBallReleased(int ballId); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs.meta new file mode 100644 index 000000000..95e784e14 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04a01977273d4b5ba036ca6c549bbf2b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs new file mode 100644 index 000000000..27032f3aa --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -0,0 +1,327 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System.Collections.Generic; +using NLog; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity.Collections; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + [PackAs("Magnet")] + [AddComponentMenu("Pinball/Mechs/Magnet")] + [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/magnets.html")] + public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDeviceComponent, IPackable, IKinematicTransformComponent + { + public const string MagnetCoilItem = "magnet_coil"; + public const string BallHeldSwitchItem = "ball_held"; + public const float MillimetersToWorld = 0.001f; + public const float DefaultPlanarDamping = 0.985f; + public const float DefaultCoilRiseTimeMs = 20f; + public const float DefaultCoilFallTimeMs = 20f; + public const float DefaultPoleRadiusMm = 10f; + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + [Min(0f)] + [Unit("mm")] + [Tooltip("Planar radius in which the magnet influences balls.")] + public float Radius = 50f; + + [Tooltip("Magnet strength. In VPX-compatible mode this uses cvpmMagnet strength values.")] + public float Strength = 10f; + + [Tooltip("Playfield magnets act through a vertical cylinder; spatial magnets grab and carry balls in 3-D.")] + public MagnetType MagnetType = VisualPinball.Unity.MagnetType.Playfield; + + [Tooltip("How the authored strength value is interpreted.")] + public MagnetForceProfile ForceProfile = MagnetForceProfile.VpxCompatible; + + [Min(0f)] + [Unit("ms")] + [Tooltip("Electrical rise time constant for Physical and Spatial magnets. The current reaches about 63% after one time constant.")] + public float CoilRiseTime = DefaultCoilRiseTimeMs; + + [Min(0f)] + [Unit("ms")] + [Tooltip("Electrical decay time constant for Physical and Spatial magnets. Set this to match the driver flyback circuit.")] + public float CoilFallTime = DefaultCoilFallTimeMs; + + [Min(0f)] + [Unit("mm")] + [Tooltip("Effective radius of the circular or annular pole face used by Physical and Spatial field profiles.")] + public float PoleRadius = DefaultPoleRadiusMm; + + [Tooltip("Whether the magnet can hold a ball at its center.")] + public bool GrabBall; + + [Min(0f)] + [Unit("mm")] + [Tooltip("Radius around the center where grab mode captures the ball.")] + public float GrabRadius = 10.8f; + + [Min(0f)] + [Unit("mm")] + [Tooltip("Vertical range above the magnet surface where balls are affected. Zero means unlimited.")] + public float HeightRange = 50f; + + [Tooltip("Whether the magnet starts enabled before coil or script control changes it.")] + public bool IsEnabledOnStart; + + [Tooltip("If set, transforming this object during gameplay moves the magnetic field with it.")] + public bool IsKinematic; + + [Tooltip("Draw play-mode force vectors for balls inside the radius.")] + public bool DrawDebugForces; + + public byte[] Pack() => MagnetPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => System.Array.Empty(); + + public void Unpack(byte[] bytes) => MagnetPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } + + public IEnumerable AvailableCoils => new[] { + new GamelogicEngineCoil(MagnetCoilItem) { + Description = "Magnet" + } + }; + + public IEnumerable AvailableSwitches => new[] { + new GamelogicEngineSwitch(BallHeldSwitchItem) { + Description = "Ball Held" + } + }; + + public SwitchDefault SwitchDefault => SwitchDefault.NormallyOpen; + + IApiCoil ICoilDeviceComponent.CoilDevice(string deviceId) => ((IApiCoilDevice)MagnetApi).Coil(deviceId); + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableSwitches; + IEnumerable IWireableComponent.AvailableWireDestinations => AvailableCoils; + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + + public MagnetApi MagnetApi { get; private set; } + + private PhysicsEngine _physicsEngine; + + private void Awake() + { + var player = GetComponentInParent(); + if (player == null) { + Logger.Error($"Cannot find player for magnet {name}."); + return; + } + + _physicsEngine = GetComponentInParent(); + MagnetApi = new MagnetApi(gameObject, player, _physicsEngine); + + player.Register(MagnetApi, this); + if (_physicsEngine) { + _physicsEngine.Register(this); + } else { + Logger.Error($"Cannot find physics engine for magnet {name}."); + } + } + + private void OnValidate() + { + Radius = math.max(0f, Radius); + GrabRadius = math.max(0f, GrabRadius); + HeightRange = math.max(0f, HeightRange); + CoilRiseTime = math.max(0f, CoilRiseTime); + CoilFallTime = math.max(0f, CoilFallTime); + PoleRadius = math.max(0f, PoleRadius); + SyncPhysicsState(); + } + + internal MagnetState CreateState() + { + var pos = GetPlayfieldPositionVpx(transform); + var commandedPower = IsEnabledOnStart ? 1f : 0f; + var usesPhysicalResponse = MagnetType == MagnetType.Spatial || ForceProfile == MagnetForceProfile.Physical; + return new MagnetState { + Position = pos.xy, + Height = pos.z, + Radius = MillimetersToVpx(Radius), + Strength = Strength, + CommandedPower = commandedPower, + EffectiveCurrent = usesPhysicalResponse ? 0f : commandedPower, + EffectiveStrength = usesPhysicalResponse ? 0f : Strength * commandedPower, + RiseTime = CoilRiseTime / MagnetPhysics.VpxMagnetUpdateMs, + FallTime = CoilFallTime / MagnetPhysics.VpxMagnetUpdateMs, + PoleRadius = MillimetersToVpx(PoleRadius), + GrabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f, + PlanarDamping = DefaultPlanarDamping, + IsEnabled = IsEnabledOnStart, + IsKinematic = IsKinematic, + // spatial magnets dispatch on MagnetType and never read Profile + Profile = ForceProfile, + HeightRange = MillimetersToVpx(HeightRange), + MagnetType = MagnetType, + GrabbedBalls = default, + ReleasedBalls = default + }; + } + + /// + /// Pushes inspector edits to the live physics state during play mode. + /// Builds a fresh state from the authored fields and preserves the + /// runtime-owned ones (coil state and grab bookkeeping). + /// + private void SyncPhysicsState() + { + if (!Application.isPlaying || !_physicsEngine) { + return; + } + + var itemId = ItemId; + var synced = CreateState(); + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.MagnetStates.ContainsKey(itemId)) { + return; + } + ref var magnet = ref state.MagnetStates.GetValueByRef(itemId); + synced.IsEnabled = magnet.IsEnabled; + synced.CommandedPower = magnet.CommandedPower; + synced.EffectiveCurrent = magnet.EffectiveCurrent; + synced.EffectiveStrength = magnet.EffectiveStrength; + synced.GrabbedBalls = magnet.GrabbedBalls; + synced.ReleasedBalls = magnet.ReleasedBalls; + magnet = synced; + }); + } + + public int ItemId => UnityObjectId.Get(gameObject); + + bool IKinematicTransformComponent.IsKinematic => IsKinematic; + + public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield) + => Physics.GetLocalToPlayfieldMatrixInVpx(transform.localToWorldMatrix, worldToPlayfield); + + public void OnTransformationChanged(float4x4 currTransformationMatrix) + { + } + + internal static float MillimetersToVpx(float value) => Physics.ScaleToVpx(value * MillimetersToWorld); + + /// + /// Playfield position in VPX space, valid for any nesting depth. The local + /// position is only equivalent when every ancestor up to the playfield sits + /// at identity, which re-parenting in the editor silently breaks. + /// + internal static float3 GetPlayfieldPositionVpx(Transform transform) + { + var playfield = transform.GetComponentInParent(); + return playfield + ? (float3)transform.position.TranslateToVpx(playfield.transform) + : (float3)transform.localPosition.TranslateToVpx(); + } + + private void OnDrawGizmosSelected() + { + var radiusWorld = Radius * MillimetersToWorld; + var poleRadiusWorld = PoleRadius * MillimetersToWorld; + var grabRadiusWorld = GrabBall ? GrabRadius * MillimetersToWorld : 0f; + var heightRangeWorld = HeightRange * MillimetersToWorld; + + Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.9f); + if (MagnetType == VisualPinball.Unity.MagnetType.Spatial) { + Gizmos.DrawWireSphere(transform.position, radiusWorld); + } else { + DrawLocalDisc(radiusWorld); + } + + if (GrabBall && grabRadiusWorld > 0f) { + Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.5f); + if (MagnetType == VisualPinball.Unity.MagnetType.Spatial) { + Gizmos.DrawWireSphere(transform.position, grabRadiusWorld); + } else { + DrawLocalDisc(grabRadiusWorld); + } + } + + if ((MagnetType == VisualPinball.Unity.MagnetType.Spatial || ForceProfile == MagnetForceProfile.Physical) && poleRadiusWorld > 0f) { + Gizmos.color = new Color(0.75f, 0.2f, 1f, 0.55f); + DrawLocalDisc(poleRadiusWorld); + } + + if (MagnetType == VisualPinball.Unity.MagnetType.Playfield && heightRangeWorld > 0f) { + Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.35f); + DrawLocalCylinder(radiusWorld, heightRangeWorld); + } + + if (!Application.isPlaying || !DrawDebugForces) { + return; + } + var player = GetComponentInParent(); + if (!player) { + return; + } + foreach (var ball in player.GetComponentsInChildren()) { + var offset = ball.transform.position - transform.position; + if (MagnetType == VisualPinball.Unity.MagnetType.Spatial) { + if (offset.sqrMagnitude <= radiusWorld * radiusWorld) { + Gizmos.DrawLine(ball.transform.position, ball.transform.position - offset.normalized * math.min(radiusWorld * 0.25f, offset.magnitude)); + } + } else { + var planarOffset = Vector3.ProjectOnPlane(offset, transform.up); + if (planarOffset.sqrMagnitude <= radiusWorld * radiusWorld) { + Gizmos.DrawLine(ball.transform.position, ball.transform.position - planarOffset.normalized * math.min(radiusWorld * 0.25f, planarOffset.magnitude)); + } + } + } + } + + private void DrawLocalDisc(float radius, float localHeight = 0f) + { + if (radius <= 0f) { + return; + } + + const int segments = 64; + var previous = transform.TransformPoint(new Vector3(radius, localHeight, 0f)); + for (var i = 1; i <= segments; i++) { + var angle = (math.TAU * i) / segments; + var next = transform.TransformPoint(new Vector3(math.cos(angle) * radius, localHeight, math.sin(angle) * radius)); + Gizmos.DrawLine(previous, next); + previous = next; + } + } + + private void DrawLocalCylinder(float radius, float height) + { + if (radius <= 0f || height <= 0f) { + return; + } + + DrawLocalDisc(radius); + DrawLocalDisc(radius, height); + + const int segments = 8; + for (var i = 0; i < segments; i++) { + var angle = (math.TAU * i) / segments; + var localBase = new Vector3(math.cos(angle) * radius, 0f, math.sin(angle) * radius); + var localTop = new Vector3(localBase.x, height, localBase.z); + Gizmos.DrawLine(transform.TransformPoint(localBase), transform.TransformPoint(localTop)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta new file mode 100644 index 000000000..9d53dd367 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf48c83fc8414ae7bb8c31f1b1854b83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 33b3a3c280b720742af8f53c484effec, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs new file mode 100644 index 000000000..ae4ac8c06 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs @@ -0,0 +1,30 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + public enum MagnetForceProfile + { + VpxCompatible = 0, + Physical = 1 + } + + public enum MagnetType + { + Playfield = 0, + Spatial = 1 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs.meta new file mode 100644 index 000000000..0262e7799 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aaadedeef0cd4bbda654fca5caa60530 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs new file mode 100644 index 000000000..7b989be34 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -0,0 +1,72 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +namespace VisualPinball.Unity +{ + public struct MagnetPackable + { + public float Radius; + public float Strength; + public MagnetType MagnetType; + public MagnetForceProfile ForceProfile; + public float CoilRiseTime; + public float CoilFallTime; + public float PoleRadius; + public bool GrabBall; + public float GrabRadius; + public float HeightRange; + public bool IsEnabledOnStart; + public bool IsKinematic; + public bool DrawDebugForces; + + public static byte[] Pack(MagnetComponent comp) + { + return PackageApi.Packer.Pack(new MagnetPackable { + Radius = comp.Radius, + Strength = comp.Strength, + MagnetType = comp.MagnetType, + ForceProfile = comp.ForceProfile, + CoilRiseTime = comp.CoilRiseTime, + CoilFallTime = comp.CoilFallTime, + PoleRadius = comp.PoleRadius, + GrabBall = comp.GrabBall, + GrabRadius = comp.GrabRadius, + HeightRange = comp.HeightRange, + IsEnabledOnStart = comp.IsEnabledOnStart, + IsKinematic = comp.IsKinematic, + DrawDebugForces = comp.DrawDebugForces, + }); + } + + public static void Unpack(byte[] bytes, MagnetComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.Radius = data.Radius; + comp.Strength = data.Strength; + comp.MagnetType = data.MagnetType; + comp.ForceProfile = data.ForceProfile; + comp.CoilRiseTime = data.CoilRiseTime; + comp.CoilFallTime = data.CoilFallTime; + comp.PoleRadius = data.PoleRadius; + comp.GrabBall = data.GrabBall; + comp.GrabRadius = data.GrabRadius; + comp.HeightRange = data.HeightRange; + comp.IsEnabledOnStart = data.IsEnabledOnStart; + comp.IsKinematic = data.IsKinematic; + comp.DrawDebugForces = data.DrawDebugForces; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta new file mode 100644 index 000000000..5c18a7303 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6bf2afc8055ef964eaad1cfb3e2d4b42 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs new file mode 100644 index 000000000..2a13b90aa --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.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 Unity.Burst; +using Unity.Mathematics; +using VisualPinball.Engine.Game; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] + internal static class MagnetPhysics + { + internal const float VpxMagnetUpdateMs = 10f; + private const float MinDistance = 0.0001f; + private const float MinDistanceSq = MinDistance * MinDistance; + private const float PhysicalMinimumPoleRadius = 1f; + private const float AnnularPoleKernelNormalization = 3.864f; + private const float PhysicalVelocityDamping = 0.02f; + private const float MinEffectiveCurrent = 0.0001f; + + [BurstCompile] + internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) + { + AdvanceCoil(ref magnet, physicsDiffTime); + if (!HasActiveField(in magnet)) { + ReleaseGrabbedBalls(itemId, ref magnet, ref state, false); + if (!state.InsideOfs.IsEmpty(itemId)) { + ReleaseMembership(itemId, ref state); + } + return; + } + + // keep the field pose fresh for enabled magnets before forces are applied + var magnetVelocity = RefreshKinematicState(itemId, ref magnet, ref state); + + // only the VPX-compatible playfield path damps planar velocity; skip the + // transcendental for spatial and physical magnets that never read it + var vpxDamping = magnet.MagnetType == MagnetType.Playfield && magnet.Profile == MagnetForceProfile.VpxCompatible + ? math.pow(magnet.PlanarDamping, physicsDiffTime) + : 0f; + + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + // a ball frozen by something else (e.g. a kicker capture) is off-limits; + // spatial magnets hold with a force, not a freeze, so they never freeze a ball + if (ball.IsFrozen) { + ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); + UpdateMembership(itemId, ball.Id, false, ref state); + continue; + } + + var affectsBall = IsBallInRange(in ball, in magnet) || IsGrabbedBall(in magnet, ref state, ball.Id); + if (!affectsBall) { + ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); + ClearReleasedBall(ref magnet, ref state, ball.Id); + UpdateMembership(itemId, ball.Id, false, ref state); + continue; + } + + UpdateMembership(itemId, ball.Id, true, ref state); + if (UpdateGrab(itemId, ref magnet, ref state, ref ball, physicsDiffTime, magnetVelocity)) { + continue; + } + + switch (magnet.MagnetType) { + case MagnetType.Spatial: + ApplySpatialPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity); + break; + default: + switch (magnet.Profile) { + case MagnetForceProfile.VpxCompatible: + ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, vpxDamping); + break; + case MagnetForceProfile.Physical: + ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity.xy); + break; + } + break; + } + } + } + } + + /// + /// Advances the normalized coil current toward its command. Physical force is + /// proportional to current squared; VPX Compatible remains instantaneous. + /// Time is expressed in the engine's normalized 10 ms units. + /// + internal static void AdvanceCoil(ref MagnetState magnet, float physicsDiffTime) + { + var command = magnet.IsEnabled ? math.saturate(magnet.CommandedPower) : 0f; + if (!UsesPhysicalResponse(in magnet)) { + magnet.EffectiveCurrent = command; + magnet.EffectiveStrength = magnet.Strength * command; + return; + } + + var timeConstant = command > magnet.EffectiveCurrent ? magnet.RiseTime : magnet.FallTime; + if (timeConstant <= MinDistance || physicsDiffTime <= 0f) { + magnet.EffectiveCurrent = command; + } else { + // Implicit Euler is stable for long frames and approximates the RL + // exponential without a transcendental in the 1 kHz loop. + var alpha = math.saturate(physicsDiffTime / (timeConstant + physicsDiffTime)); + magnet.EffectiveCurrent = math.lerp(magnet.EffectiveCurrent, command, alpha); + if (math.abs(magnet.EffectiveCurrent - command) <= MinEffectiveCurrent) { + magnet.EffectiveCurrent = command; + } + } + magnet.EffectiveStrength = magnet.Strength * magnet.EffectiveCurrent * magnet.EffectiveCurrent; + } + + internal static float3 RefreshKinematicState(int itemId, ref MagnetState magnet, ref PhysicsState state) + { + if (!magnet.IsKinematic || !state.KinematicTransforms.TryGetValue(itemId, out var matrix)) { + return float3.zero; + } + + ApplyKinematicTransform(ref magnet, in matrix); + return GetKinematicVelocity(itemId, in magnet, ref state); + } + + internal static void ApplyKinematicTransform(ref MagnetState magnet, in float4x4 matrix) + { + magnet.Position = matrix.c3.xy; + magnet.Height = matrix.c3.z; + } + + internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab) + { + // the ball is a live physics object throughout the hold, so releasing it is + // just dropping the hold force — it keeps whatever velocity it currently has + if (magnet.GrabbedBalls.Value != 0UL) { + for (var bitIndex = 0; bitIndex < 64; bitIndex++) { + if (!magnet.GrabbedBalls.IsSet(bitIndex)) { + continue; + } + magnet.GrabbedBalls.SetBits(bitIndex, false); + if (suppressRegrab) { + magnet.ReleasedBalls.SetBits(bitIndex, true); + } + if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + } + } + } + + if (!suppressRegrab) { + magnet.ReleasedBalls = default; + } + } + + internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, float speed, float angleDeg, float verticalAngleDeg = 0f) + { + if (magnet.GrabbedBalls.Value == 0UL) { + return; + } + + // a ball thrown from a moving magnet keeps the carrier velocity, same as + // releasing the coil mid-move does; refresh the pose first so a kinematic + // magnet ejected between ticks uses its current hold point, not last tick's + var carrierVelocity = RefreshKinematicState(itemId, ref magnet, ref state); + + for (var bitIndex = 0; bitIndex < 64; bitIndex++) { + if (!magnet.GrabbedBalls.IsSet(bitIndex)) { + continue; + } + if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { + if (state.Balls.ContainsKey(ballId)) { + ref var ball = ref state.Balls.GetValueByRef(ballId); + if (magnet.MagnetType == MagnetType.Spatial) { + ApplySpatialEject(ref ball, speed, angleDeg, verticalAngleDeg, carrierVelocity); + } else { + ApplyPlanarEject(ref ball, speed, angleDeg, carrierVelocity.xy); + } + } + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + } + magnet.GrabbedBalls.SetBits(bitIndex, false); + magnet.ReleasedBalls.SetBits(bitIndex, true); + } + } + + internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) + => ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, math.pow(magnet.PlanarDamping, physicsDiffTime)); + + internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float damping) + { + var delta = ball.Position.xy - magnet.Position; + var distance = math.length(delta); + if (distance <= MinDistance || magnet.Radius <= MinDistance) { + return; + } + + // cvpmMagnet.AttractBall: ratio = dist / (1.5*Size), then the damping wraps + // both the old velocity and the impulse: (vel - dir*force) * 0.985 + var ratio = distance / (1.5f * magnet.Radius); + var force = magnet.EffectiveStrength * math.exp(-0.2f / ratio) / (ratio * ratio * 56f) * 1.5f; + var direction = delta / distance; + + var velocity = (ball.Velocity.xy - direction * force * physicsDiffTime) * damping; + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + } + + internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity = default) + { + var delta = ball.Position.xy - magnet.Position; + var distanceSq = math.lengthsq(delta); + if (distanceSq <= MinDistanceSq) { + return; + } + + var distance = math.sqrt(distanceSq); + var height = math.max(0f, ball.Position.z - magnet.Height); + var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); + if (magnet.HeightRange > 0f) { + cutoff *= CompactSupport(height * height, magnet.HeightRange * magnet.HeightRange); + } + var force = PhysicalForceMagnitude(distance, height, cutoff, in magnet); + var direction = delta / distance; + var velocity = ball.Velocity.xy - direction * force * physicsDiffTime; + + var damping = math.saturate(math.abs(force) * PhysicalVelocityDamping * physicsDiffTime); + velocity = magnetVelocity + (velocity - magnetVelocity) * (1f - damping); + + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + } + + internal static void ApplySpatialPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float3 magnetVelocity = default) + { + var delta = ball.Position - Center3D(in magnet); + var distanceSq = math.lengthsq(delta); + if (distanceSq <= MinDistanceSq) { + return; + } + + var distance = math.sqrt(distanceSq); + var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); + var force = PhysicalForceMagnitude(distance, 0f, cutoff, in magnet); + var direction = delta / distance; + var velocity = ball.Velocity - direction * force * physicsDiffTime; + + var damping = math.saturate(math.abs(force) * PhysicalVelocityDamping * physicsDiffTime); + velocity = magnetVelocity + (velocity - magnetVelocity) * (1f - damping); + + ball.Velocity = velocity; + } + + internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet, float2 magnetVelocity = default) + { + ball.Position = new float3(magnet.Position.x, magnet.Position.y, ball.Position.z); + ball.EventPosition = new float3(magnet.Position.x, magnet.Position.y, ball.EventPosition.z); + ball.Velocity = new float3(magnetVelocity.x, magnetVelocity.y, ball.Velocity.z); + ball.OldVelocity = new float3(magnetVelocity.x, magnetVelocity.y, ball.OldVelocity.z); + ball.AngularMomentum = float3.zero; + } + + /// + /// 3-D critically-damped spring pulling the ball toward the moving hold point. + /// The ball stays a live physics object — it renders and collides normally, and + /// the capped impulse means a hard enough hit from another ball overcomes the + /// hold and knocks it loose (the next tick it leaves the grab radius and is + /// released). This is the spatial analog of . + /// + internal static void ApplySpatialPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float3 magnetVelocity = default) + { + var offset = ball.Position - Center3D(in magnet); + var relativeVelocity = ball.Velocity - magnetVelocity; + var holdStrength = math.abs(magnet.EffectiveStrength); + if (holdStrength <= MinDistance) { + return; + } + var holdRadius = math.max(magnet.GrabRadius, math.max(magnet.PoleRadius, PhysicalMinimumPoleRadius)); + var stiffness = holdStrength / holdRadius; + var damping = 2f * math.sqrt(stiffness); + var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; + var maxImpulse = holdStrength * physicsDiffTime; + var impulseLenSq = math.lengthsq(impulse); + var maxImpulseSq = maxImpulse * maxImpulse; + + if (impulseLenSq > maxImpulseSq && impulseLenSq > MinDistanceSq) { + impulse *= maxImpulse / math.sqrt(impulseLenSq); + } + + ball.Velocity += impulse; + ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * 0.5f); + } + + internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity = default) + { + var offset = ball.Position.xy - magnet.Position; + var velocity = ball.Velocity.xy; + var relativeVelocity = velocity - magnetVelocity; + var holdStrength = math.abs(magnet.EffectiveStrength); + if (holdStrength <= MinDistance) { + return; + } + var holdRadius = math.max(magnet.GrabRadius, math.max(magnet.PoleRadius, PhysicalMinimumPoleRadius)); + var stiffness = holdStrength / holdRadius; + var damping = 2f * math.sqrt(stiffness); + var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; + var maxImpulse = holdStrength * physicsDiffTime; + var impulseLenSq = math.lengthsq(impulse); + var maxImpulseSq = maxImpulse * maxImpulse; + + if (impulseLenSq > maxImpulseSq && impulseLenSq > MinDistanceSq) { + impulse *= maxImpulse / math.sqrt(impulseLenSq); + } + + velocity += impulse; + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * 0.5f); + } + + internal static void ApplyPlanarEject(ref BallState ball, float speed, float angleDeg, float2 carrierVelocity = default) + { + var angleRad = math.radians(angleDeg); + var velocity = carrierVelocity + new float2( + math.sin(angleRad) * speed, + -math.cos(angleRad) * speed + ); + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + ball.OldVelocity = new float3(velocity.x, velocity.y, ball.OldVelocity.z); + ball.AngularMomentum = float3.zero; + } + + internal static void ApplySpatialEject(ref BallState ball, float speed, float angleDeg, float verticalAngleDeg, float3 carrierVelocity = default) + { + var angleRad = math.radians(angleDeg); + var verticalRad = math.radians(verticalAngleDeg); + var horizontalSpeed = speed * math.cos(verticalRad); + var velocity = carrierVelocity + new float3( + math.sin(angleRad) * horizontalSpeed, + -math.cos(angleRad) * horizontalSpeed, + math.sin(verticalRad) * speed + ); + + ball.Velocity = velocity; + ball.OldVelocity = velocity; + ball.AngularMomentum = float3.zero; + } + + internal static float3 Center3D(in MagnetState magnet) + { + return new float3(magnet.Position.x, magnet.Position.y, magnet.Height); + } + + internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) + { + if (magnet.Radius <= 0f) { + return false; + } + if (magnet.MagnetType == MagnetType.Spatial) { + return math.lengthsq(ball.Position - Center3D(in magnet)) <= magnet.Radius * magnet.Radius; + } + if (magnet.HeightRange > 0f && (ball.Position.z < magnet.Height || ball.Position.z > magnet.Height + magnet.HeightRange)) { + return false; + } + return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; + } + + /// + /// A held ball stays owned by the magnet even when a moving field carries its + /// center outside the coarse influence volume. The profile-specific hold logic + /// is responsible for deciding whether the ball has actually broken free. + /// + private static bool IsGrabbedBall(in MagnetState magnet, ref PhysicsState state, int ballId) + => magnet.GrabbedBalls.Value != 0UL + && state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex) + && magnet.GrabbedBalls.IsSet(bitIndex); + + /// + /// Shared grab bookkeeping for both magnet kinds. The only differences are the + /// distance metric (spherical for spatial, planar for playfield) and which hold + /// is applied. Because the hold is a force, a ball knocked out of the grab + /// radius simply fails the range check next tick and is released. + /// + private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float3 magnetVelocity) + { + // plain attraction magnets never grab; skip the bookkeeping entirely + if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { + return false; + } + + var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); + var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); + var usesPhysicalCapture = UsesPhysicalResponse(in magnet); + var distanceSq = magnet.MagnetType == MagnetType.Spatial + ? math.lengthsq(ball.Position - Center3D(in magnet)) + : math.lengthsq(ball.Position.xy - magnet.Position); + var hasGrabForce = usesPhysicalCapture + ? math.abs(magnet.EffectiveStrength) > MinDistance + : magnet.EffectiveStrength > 0f; + var isInGrabRange = magnet.GrabRadius > 0f && + hasGrabForce && + distanceSq <= magnet.GrabRadius * magnet.GrabRadius; + + if (!isInGrabRange) { + magnet.ReleasedBalls.SetBits(bitIndex, false); + if (isGrabbed) { + ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ball.Id, ref state, false); + } + return false; + } + + if (magnet.ReleasedBalls.IsSet(bitIndex)) { + return false; + } + + if (!isGrabbed) { + if (usesPhysicalCapture && !CanCapturePhysical(in ball, in magnet, distanceSq, magnetVelocity)) { + return false; + } + magnet.GrabbedBalls.SetBits(bitIndex, true); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ball.Id, true)); + } + switch (magnet.MagnetType) { + case MagnetType.Spatial: + ApplySpatialPhysicalHold(ref ball, in magnet, physicsDiffTime, magnetVelocity); + break; + default: + switch (magnet.Profile) { + case MagnetForceProfile.Physical: + ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime, magnetVelocity.xy); + break; + default: + ApplyVpxCompatibleGrab(ref ball, in magnet, magnetVelocity.xy); + break; + } + break; + } + return true; + } + + /// + /// A physical grab starts only when the available hold acceleration can arrest + /// relative motion before the ball crosses the remaining grab volume. + /// + internal static bool CanCapturePhysical(in BallState ball, in MagnetState magnet, float distanceSq, float3 magnetVelocity) + { + var distance = math.sqrt(math.max(0f, distanceSq)); + var remainingDistance = math.max(0f, magnet.GrabRadius - distance); + var relativeSpeedSq = magnet.MagnetType == MagnetType.Spatial + ? math.lengthsq(ball.Velocity - magnetVelocity) + : math.lengthsq(ball.Velocity.xy - magnetVelocity.xy); + var availableAcceleration = math.abs(magnet.EffectiveStrength); + return relativeSpeedSq <= 2f * availableAcceleration * remainingDistance; + } + + /// + /// Compact approximation to the lateral gradient of B squared above a finite, + /// axisymmetric pole face. The radial force is zero on-axis, strongest in an + /// annulus around the pole, and falls with the fifth power in the far field. + /// + internal static float PhysicalForceMagnitude(float radialDistance, float axialDistance, float cutoff, in MagnetState magnet) + { + if (cutoff <= 0f) { + return 0f; + } + var poleRadius = math.max(PhysicalMinimumPoleRadius, magnet.PoleRadius); + var radialRatio = radialDistance / poleRadius; + var axialRatio = axialDistance / poleRadius; + var denominator = 1f + radialRatio * radialRatio + axialRatio * axialRatio; + var kernel = AnnularPoleKernelNormalization * radialRatio / (denominator * denominator * denominator); + return math.abs(magnet.EffectiveStrength) * kernel * cutoff / (poleRadius * poleRadius); + } + + private static float CompactSupport(float distanceSq, float radiusSq) + { + if (radiusSq <= MinDistanceSq || distanceSq >= radiusSq) { + return 0f; + } + var remaining = 1f - distanceSq / radiusSq; + return remaining * remaining; + } + + private static bool UsesPhysicalResponse(in MagnetState magnet) + => magnet.MagnetType == MagnetType.Spatial || magnet.Profile == MagnetForceProfile.Physical; + + private static bool HasActiveField(in MagnetState magnet) + => UsesPhysicalResponse(in magnet) + ? magnet.EffectiveCurrent > MinEffectiveCurrent && math.abs(magnet.Strength) > MinDistance + : magnet.IsEnabled && magnet.CommandedPower > 0f; + + private static float3 GetKinematicVelocity(int itemId, in MagnetState magnet, ref PhysicsState state) + { + return state.GetKinematicVelocityAt(itemId, Center3D(in magnet)); + } + + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, int ballId) + { + if (!state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + return; + } + ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ballId, ref state, false); + } + + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab) + { + if (!magnet.GrabbedBalls.IsSet(bitIndex)) { + return; + } + magnet.GrabbedBalls.SetBits(bitIndex, false); + if (suppressRegrab) { + magnet.ReleasedBalls.SetBits(bitIndex, true); + } + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + } + + private static void ClearReleasedBall(ref MagnetState magnet, ref PhysicsState state, int ballId) + { + if (state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + magnet.ReleasedBalls.SetBits(bitIndex, false); + } + } + + private static void ReleaseMembership(int itemId, ref PhysicsState state) + { + var ballIds = state.InsideOfs.GetIdsOfBallsInsideItem(itemId); + for (var i = 0; i < ballIds.Length; i++) { + var ballId = ballIds[i]; + state.InsideOfs.SetOutsideOf(itemId, ballId); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallExited, itemId, ballId, true)); + } + } + + private static void UpdateMembership(int itemId, int ballId, bool isInside, ref PhysicsState state) + { + var wasInside = state.InsideOfs.IsInsideOf(itemId, ballId); + if (isInside == wasInside) { + return; + } + + if (isInside) { + state.InsideOfs.SetInsideOf(itemId, ballId); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallEntered, itemId, ballId, true)); + } else { + state.InsideOfs.SetOutsideOf(itemId, ballId); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallExited, itemId, ballId, true)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta new file mode 100644 index 000000000..fc9f3a2fa --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ced4b8e5bf4790443948b675a0732eef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs new file mode 100644 index 000000000..114049dd2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -0,0 +1,47 @@ +// 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.Runtime.InteropServices; +using Unity.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal struct MagnetState + { + internal float2 Position; + internal float Height; + internal float Radius; + internal float Strength; + internal float CommandedPower; + internal float EffectiveCurrent; + internal float EffectiveStrength; + internal float RiseTime; + internal float FallTime; + internal float PoleRadius; + internal float GrabRadius; + internal float PlanarDamping; + [MarshalAs(UnmanagedType.U1)] + internal bool IsEnabled; + [MarshalAs(UnmanagedType.U1)] + internal bool IsKinematic; + internal MagnetForceProfile Profile; + internal float HeightRange; + internal MagnetType MagnetType; + internal BitField64 GrabbedBalls; + internal BitField64 ReleasedBalls; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs.meta new file mode 100644 index 000000000..b75c958e2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fbc1a7f18cfd4cb79f71f0281d0a85be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs index cce1c281d..bb9638504 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs @@ -35,6 +35,7 @@ public class TableApi : IApi private readonly Dictionary _kickersByName = new Dictionary(); private readonly Dictionary _lightsByName = new Dictionary(); private readonly Dictionary _lightGroupsByName = new Dictionary(); + private readonly Dictionary _magnetsByName = new Dictionary(); private readonly Dictionary _plungersByName = new Dictionary(); private readonly Dictionary _primitivesByName = new Dictionary(); private readonly Dictionary _rampsByName = new Dictionary(); @@ -45,6 +46,7 @@ public class TableApi : IApi private readonly Dictionary _teleportersByName = new Dictionary(); private readonly Dictionary _triggersByName = new Dictionary(); private readonly Dictionary _troughsByName = new Dictionary(); + private readonly Dictionary _turntablesByName = new Dictionary(); private readonly Dictionary _metalWireGuidesByName = new Dictionary(); private readonly Dictionary _switchablesByName = new Dictionary(); private readonly Dictionary _hittablesByName = new Dictionary(); @@ -58,6 +60,7 @@ public class TableApi : IApi private readonly Dictionary _kickersByComponent = new Dictionary(); private readonly Dictionary _lightsByComponent = new Dictionary(); private readonly Dictionary _lightGroupsByComponent = new Dictionary(); + private readonly Dictionary _magnetsByComponent = new Dictionary(); private readonly Dictionary _plungersByComponent = new Dictionary(); private readonly Dictionary _primitivesByComponent = new Dictionary(); private readonly Dictionary _rampsByComponent = new Dictionary(); @@ -68,6 +71,7 @@ public class TableApi : IApi private readonly Dictionary _teleportersByComponent = new Dictionary(); private readonly Dictionary _triggersByComponent = new Dictionary(); private readonly Dictionary _troughsByComponent = new Dictionary(); + private readonly Dictionary _turntablesByComponent = new Dictionary(); private readonly Dictionary _metalWireGuidesByComponent = new Dictionary(); private readonly Dictionary _switchablesByComponent = new Dictionary(); private readonly Dictionary _hittablesByComponent = new Dictionary(); @@ -129,6 +133,22 @@ public TableApi(Player player) public KickerApi Kicker(string name) => Get(name); public KickerApi Kicker(MonoBehaviour component) => Get(component); + /// + /// Returns a magnet by name. + /// + /// Name of the magnet + /// Magnet or `null` if no magnet with that name exists. + public MagnetApi Magnet(string name) => Get(name); + public MagnetApi Magnet(MonoBehaviour component) => Get(component); + + /// + /// Returns a turntable by name. + /// + /// Name of the turntable + /// Turntable or `null` if no turntable with that name exists. + public TurntableApi Turntable(string name) => Get(name); + public TurntableApi Turntable(MonoBehaviour component) => Get(component); + /// /// Returns a light by name. /// @@ -294,6 +314,7 @@ private Dictionary GetNameDictionary(Type t) where T : IApi if (t == typeof(KickerApi)) return _kickersByName as Dictionary; if (t == typeof(LightApi)) return _lightsByName as Dictionary; if (t == typeof(LightGroupApi)) return _lightGroupsByName as Dictionary; + if (t == typeof(MagnetApi)) return _magnetsByName as Dictionary; if (t == typeof(PlungerApi)) return _plungersByName as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByName as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByName as Dictionary; @@ -305,6 +326,7 @@ private Dictionary GetNameDictionary(Type t) where T : IApi if (t == typeof(TeleporterApi)) return _teleportersByName as Dictionary; if (t == typeof(TriggerApi)) return _triggersByName as Dictionary; if (t == typeof(TroughApi)) return _troughsByName as Dictionary; + if (t == typeof(TurntableApi)) return _turntablesByName as Dictionary; if (t == typeof(MetalWireGuideApi)) return _metalWireGuidesByName as Dictionary; // can be null, because we don't track all elements, namely hittable switches @@ -322,6 +344,7 @@ private Dictionary GetComponentDictionary(Type t) where T : if (t == typeof(KickerApi)) return _kickersByComponent as Dictionary; if (t == typeof(LightApi)) return _lightsByComponent as Dictionary; if (t == typeof(LightGroupApi)) return _lightGroupsByComponent as Dictionary; + if (t == typeof(MagnetApi)) return _magnetsByComponent as Dictionary; if (t == typeof(PlungerApi)) return _plungersByComponent as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByComponent as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByComponent as Dictionary; @@ -333,6 +356,7 @@ private Dictionary GetComponentDictionary(Type t) where T : if (t == typeof(TeleporterApi)) return _teleportersByComponent as Dictionary; if (t == typeof(TriggerApi)) return _triggersByComponent as Dictionary; if (t == typeof(TroughApi)) return _troughsByComponent as Dictionary; + if (t == typeof(TurntableApi)) return _turntablesByComponent as Dictionary; if (t == typeof(MetalWireGuideApi)) return _metalWireGuidesByComponent as Dictionary; // can be null, because we don't track all elements, namely hittable switches diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs index b9aeb9dab..9a0f8f2d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs @@ -36,6 +36,7 @@ namespace VisualPinball.Unity [DisallowMultipleComponent] [PackAs("TiltBob")] [AddComponentMenu("Pinball/Mechs/Tilt Bob")] + [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/tilt-bobs.html")] public sealed class TiltBobComponent : MonoBehaviour, ISwitchDeviceComponent, IPackable { public const string SwitchItem = "tilt_bob_switch"; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta index bdab21763..9fe4e7e3c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {instanceID: 0} + icon: {fileID: 2800000, guid: 84a0fc54e5c386a45bd366b815b5f90f, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable.meta new file mode 100644 index 000000000..be7dead52 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 904cdaeff4724eba9a4985a3e4c310df +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs new file mode 100644 index 000000000..f746eb3fc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs @@ -0,0 +1,182 @@ +// 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 Unity.Mathematics; +using UnityEngine; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + public class TurntableApi : IApi, IApiCoilDevice, IApiWireDeviceDest + { + private readonly TurntableComponent _component; + private readonly Player _player; + private readonly PhysicsEngine _physicsEngine; + private readonly int _itemId; + + private readonly DeviceCoil _motorCoil; + private readonly DeviceCoil _directionCoil; + private bool _motorOn; + private bool _spinClockwise; + + public event EventHandler Init; + + internal TurntableApi(GameObject go, Player player, PhysicsEngine physicsEngine) + { + _component = go.GetComponentInChildren(); + _player = player; + _physicsEngine = physicsEngine; + _itemId = UnityObjectId.Get(_component.gameObject); + _motorOn = _component.MotorOnStart; + _spinClockwise = _component.SpinClockwise; + + _motorCoil = new DeviceCoil(_player, OnMotorCoilEnabled, OnMotorCoilDisabled, OnMotorCoilEnabledSimThread, OnMotorCoilDisabledSimThread); + _directionCoil = new DeviceCoil(_player, OnDirectionCoilEnabled, OnDirectionCoilDisabled, OnDirectionCoilEnabledSimThread, OnDirectionCoilDisabledSimThread); + } + + public bool MotorOn { + get => _motorOn; + set => SetMotorOn(value); + } + + public bool SpinClockwise { + get => _spinClockwise; + set => SetSpinClockwise(value); + } + + public float Speed { + get => _component.PublishedSpeed; + } + + public float RotationAngle { + get => _component.PublishedRotationAngle; + } + + public float MaxSpeed { + get => _component.MaxSpeed; + set { + _component.MaxSpeed = value; + MutateState((ref TurntableState turntable) => { + turntable.MaxSpeed = value; + TurntablePhysics.RefreshTargetSpeed(ref turntable); + }); + } + } + + public float SpinUp { + get => _component.SpinUp; + set { + var spinUp = math.max(0f, value); + _component.SpinUp = spinUp; + MutateState((ref TurntableState turntable) => turntable.SpinUp = spinUp); + } + } + + public float SpinDown { + get => _component.SpinDown; + set { + var spinDown = math.max(0f, value); + _component.SpinDown = spinDown; + MutateState((ref TurntableState turntable) => turntable.SpinDown = spinDown); + } + } + + void IApi.OnInit(BallManager ballManager) + { + SetMotorOn(_component.MotorOnStart); + SetSpinClockwise(_component.SpinClockwise); + Init?.Invoke(this, EventArgs.Empty); + } + + void IApi.OnDestroy() + { + } + + IApiCoil IApiCoilDevice.Coil(string deviceItem) => Coil(deviceItem); + IApiWireDest IApiWireDeviceDest.Wire(string deviceItem) => Coil(deviceItem); + + private IApiCoil Coil(string deviceItem) + { + return deviceItem switch { + TurntableComponent.MotorCoilItem => _motorCoil, + TurntableComponent.DirectionCoilItem => _directionCoil, + _ => throw new ArgumentException($"Unknown turntable coil \"{deviceItem}\". Valid names are \"{TurntableComponent.MotorCoilItem}\" and \"{TurntableComponent.DirectionCoilItem}\".") + }; + } + + private void OnMotorCoilEnabled() => SetMotorOn(true); + private void OnMotorCoilDisabled() => SetMotorOn(false); + private void OnMotorCoilEnabledSimThread() => SetMotorOnSimThread(true); + private void OnMotorCoilDisabledSimThread() => SetMotorOnSimThread(false); + private void OnDirectionCoilEnabled() => SetSpinClockwise(true); + private void OnDirectionCoilDisabled() => SetSpinClockwise(false); + private void OnDirectionCoilEnabledSimThread() => SetSpinClockwiseSimThread(true); + private void OnDirectionCoilDisabledSimThread() => SetSpinClockwiseSimThread(false); + + private void SetMotorOn(bool motorOn) + { + _motorOn = motorOn; + MutateState((ref TurntableState turntable) => turntable.MotorOn = motorOn); + } + + private void SetSpinClockwise(bool spinClockwise) + { + _spinClockwise = spinClockwise; + MutateState((ref TurntableState turntable) => { + turntable.SpinClockwise = spinClockwise; + TurntablePhysics.RefreshTargetSpeed(ref turntable); + }); + } + + private void SetMotorOnSimThread(bool motorOn) + { + _motorOn = motorOn; + if (!_physicsEngine) { + return; + } + ref var turntable = ref _physicsEngine.TurntableState(_itemId); + turntable.MotorOn = motorOn; + } + + private void SetSpinClockwiseSimThread(bool spinClockwise) + { + _spinClockwise = spinClockwise; + if (!_physicsEngine) { + return; + } + ref var turntable = ref _physicsEngine.TurntableState(_itemId); + turntable.SpinClockwise = spinClockwise; + TurntablePhysics.RefreshTargetSpeed(ref turntable); + } + + private delegate void TurntableStateMutation(ref TurntableState turntable); + + private void MutateState(TurntableStateMutation mutation) + { + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.TurntableStates.ContainsKey(_itemId)) { + return; + } + ref var turntable = ref state.TurntableStates.GetValueByRef(_itemId); + mutation(ref turntable); + }); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs.meta new file mode 100644 index 000000000..2951c05e7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 204b8f5322954140abcc2c2e6290ac7a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs new file mode 100644 index 000000000..31a2718b8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -0,0 +1,247 @@ +// 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 NLog; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity.Collections; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + [PackAs("Turntable")] + [AddComponentMenu("Pinball/Mechs/Turntable")] + [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/magnets.html")] + public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable, IKinematicTransformComponent, IAnimationValueEmitter + { + public const string MotorCoilItem = "motor_coil"; + public const string DirectionCoilItem = "direction_coil"; + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + [Min(0f)] + [Unit("mm")] + [Tooltip("Planar radius in which the turntable influences balls.")] + public float Radius = 60f; + + [Min(0f)] + [Unit("mm")] + [Tooltip("Vertical range above the disc surface where balls are affected. Zero means unlimited.")] + public float HeightRange = 50f; + + [Tooltip("VPX-compatible maximum turntable speed.")] + public float MaxSpeed = 100f; + + [Min(0f)] + [Tooltip("Acceleration toward MaxSpeed in speed units per second. cvpmTurntable defaults to 10.")] + public float SpinUp = 10f; + + [Min(0f)] + [Tooltip("Deceleration toward zero in speed units per second. cvpmTurntable defaults to 4.")] + public float SpinDown = 4f; + + [Tooltip("Whether the turntable motor starts enabled before coil or script control changes it.")] + public bool MotorOnStart; + + [Tooltip("Initial spin direction.")] + public bool SpinClockwise = true; + + [Tooltip("If set, transforming this object during gameplay moves the turntable force field with it.")] + public bool IsKinematic; + + [Tooltip("Optional visual disc to rotate with the simulated speed.")] + public Transform RotationTarget; + + [Min(0f)] + [Tooltip("Degrees per second the visual disc rotates per speed unit. At the VPX-typical speed of 90, the default of 4 spins the disc at 60 RPM.")] + public float VisualSpeedFactor = 4f; + + public TurntableApi TurntableApi { get; private set; } + + private PhysicsEngine _physicsEngine; + private Transform _rotationTarget; + private Quaternion _rotationTargetInitialRotation; + private float2 _animationValue; + + internal float PublishedSpeed => _animationValue.x; + internal float PublishedRotationAngle => _animationValue.y; + public event Action OnAnimationValueChanged; + + public IEnumerable AvailableCoils => new[] { + new GamelogicEngineCoil(MotorCoilItem) { + Description = "Motor" + }, + new GamelogicEngineCoil(DirectionCoilItem) { + Description = "Direction" + } + }; + + IApiCoil ICoilDeviceComponent.CoilDevice(string deviceId) => ((IApiCoilDevice)TurntableApi).Coil(deviceId); + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + IEnumerable IWireableComponent.AvailableWireDestinations => AvailableCoils; + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + + public byte[] Pack() => TurntablePackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => TurntableReferencesPackable.Pack(this, refs); + + public void Unpack(byte[] bytes) => TurntablePackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) + => TurntableReferencesPackable.Unpack(data, this, refs); + + private void Awake() + { + var player = GetComponentInParent(); + if (player == null) { + Logger.Error($"Cannot find player for turntable {name}."); + return; + } + + _physicsEngine = GetComponentInParent(); + TurntableApi = new TurntableApi(gameObject, player, _physicsEngine); + + player.Register(TurntableApi, this); + if (_physicsEngine) { + _physicsEngine.Register(this); + } else { + Logger.Error($"Cannot find physics engine for turntable {name}."); + } + } + + private void OnValidate() + { + Radius = math.max(0f, Radius); + HeightRange = math.max(0f, HeightRange); + SpinUp = math.max(0f, SpinUp); + SpinDown = math.max(0f, SpinDown); + VisualSpeedFactor = math.max(0f, VisualSpeedFactor); + SyncPhysicsState(); + } + + /// + /// Pushes inspector edits to the live physics state during play mode. + /// Builds a fresh state from the authored fields and preserves the + /// runtime-owned ones (motor, direction, current speed and angle). + /// + private void SyncPhysicsState() + { + if (!Application.isPlaying || !_physicsEngine) { + return; + } + + var itemId = ItemId; + var synced = CreateState(); + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.TurntableStates.ContainsKey(itemId)) { + return; + } + ref var turntable = ref state.TurntableStates.GetValueByRef(itemId); + synced.Speed = turntable.Speed; + synced.MotorOn = turntable.MotorOn; + synced.SpinClockwise = turntable.SpinClockwise; + synced.RotationAngle = turntable.RotationAngle; + TurntablePhysics.RefreshTargetSpeed(ref synced); + turntable = synced; + }); + } + + private void Update() + { + if (TurntableApi == null) { + return; + } + if (_rotationTarget != RotationTarget) { + // restore the previous disc to its authored pose, so a re-captured + // baseline never contains accumulated simulated spin + if (_rotationTarget) { + _rotationTarget.localRotation = _rotationTargetInitialRotation; + } + _rotationTarget = RotationTarget; + if (_rotationTarget) { + _rotationTargetInitialRotation = _rotationTarget.localRotation; + } + } + if (!_rotationTarget) { + return; + } + _rotationTarget.localRotation = _rotationTargetInitialRotation * Quaternion.AngleAxis(PublishedRotationAngle, Vector3.up); + } + + public void UpdateAnimationValue(float2 value) + { + if (_animationValue.Equals(value)) { + return; + } + _animationValue = value; + OnAnimationValueChanged?.Invoke(value); + } + + internal TurntableState CreateState() + { + var pos = MagnetComponent.GetPlayfieldPositionVpx(transform); + var state = new TurntableState { + Position = pos.xy, + Height = pos.z, + Radius = MagnetComponent.MillimetersToVpx(Radius), + HeightRange = MagnetComponent.MillimetersToVpx(HeightRange), + Speed = 0f, + MaxSpeed = MaxSpeed, + SpinUp = SpinUp, + SpinDown = SpinDown, + MotorOn = MotorOnStart, + SpinClockwise = SpinClockwise, + IsKinematic = IsKinematic, + RotationAngle = 0f, + VisualSpeedFactor = VisualSpeedFactor + }; + TurntablePhysics.RefreshTargetSpeed(ref state); + return state; + } + + public int ItemId => UnityObjectId.Get(gameObject); + + bool IKinematicTransformComponent.IsKinematic => IsKinematic; + + public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield) + => Physics.GetLocalToPlayfieldMatrixInVpx(transform.localToWorldMatrix, worldToPlayfield); + + public void OnTransformationChanged(float4x4 currTransformationMatrix) + { + } + + private void OnDrawGizmosSelected() + { + var radiusWorld = Radius * MagnetComponent.MillimetersToWorld; + if (radiusWorld <= 0f) { + return; + } + + Gizmos.color = new Color(0.95f, 0.75f, 0.1f, 0.9f); + const int segments = 64; + var previous = transform.TransformPoint(new Vector3(radiusWorld, 0f, 0f)); + for (var i = 1; i <= segments; i++) { + var angle = (math.TAU * i) / segments; + var next = transform.TransformPoint(new Vector3(math.cos(angle) * radiusWorld, 0f, math.sin(angle) * radiusWorld)); + Gizmos.DrawLine(previous, next); + previous = next; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs.meta new file mode 100644 index 000000000..46c7e8570 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7013a596da5e46dd93e7e3482afb8112 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs new file mode 100644 index 000000000..a2cafa960 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs @@ -0,0 +1,80 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; + +namespace VisualPinball.Unity +{ + public struct TurntableReferencesPackable + { + public ReferencePackable RotationTargetRef; + + public static byte[] Pack(TurntableComponent comp, PackagedRefs refs) + { + return PackageApi.Packer.Pack(new TurntableReferencesPackable { + RotationTargetRef = refs.PackReference(comp.RotationTarget) + }); + } + + public static void Unpack(byte[] bytes, TurntableComponent comp, PackagedRefs refs) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.RotationTarget = refs.Resolve(data.RotationTargetRef); + } + } + + public class TurntablePackable + { + public float Radius; + public float HeightRange; + public float MaxSpeed; + public float SpinUp; + public float SpinDown; + public bool MotorOnStart; + public bool SpinClockwise; + public bool IsKinematic; + public float VisualSpeedFactor; + + public static byte[] Pack(TurntableComponent comp) + { + return PackageApi.Packer.Pack(new TurntablePackable { + Radius = comp.Radius, + HeightRange = comp.HeightRange, + MaxSpeed = comp.MaxSpeed, + SpinUp = comp.SpinUp, + SpinDown = comp.SpinDown, + MotorOnStart = comp.MotorOnStart, + SpinClockwise = comp.SpinClockwise, + IsKinematic = comp.IsKinematic, + VisualSpeedFactor = comp.VisualSpeedFactor + }); + } + + public static void Unpack(byte[] bytes, TurntableComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.Radius = data.Radius; + comp.HeightRange = data.HeightRange; + comp.MaxSpeed = data.MaxSpeed; + comp.SpinUp = data.SpinUp; + comp.SpinDown = data.SpinDown; + comp.MotorOnStart = data.MotorOnStart; + comp.SpinClockwise = data.SpinClockwise; + comp.IsKinematic = data.IsKinematic; + comp.VisualSpeedFactor = data.VisualSpeedFactor; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs.meta new file mode 100644 index 000000000..7750a76a5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ed3eafd6ac11441d87d71662a7576d36 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs new file mode 100644 index 000000000..2e45727be --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -0,0 +1,107 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Unity.Burst; +using Unity.Mathematics; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity +{ + [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] + internal static class TurntablePhysics + { + private const float VpxTurntableForceDivisor = 8000f; + private const float SecondsPerVpxUpdate = PhysicsConstants.DefaultStepTimeS; + + [BurstCompile] + internal static void Update(int itemId, ref TurntableState turntable, ref PhysicsState state, float physicsDiffTime) + { + RefreshKinematicState(itemId, ref turntable, ref state); + UpdateSpeed(ref turntable, physicsDiffTime); + // Speed is a VPX-arbitrary force scale, not a rotation rate; VisualSpeedFactor + // maps it to degrees per second for the visual disc. + turntable.RotationAngle = math.fmod(turntable.RotationAngle + turntable.Speed * turntable.VisualSpeedFactor * physicsDiffTime * SecondsPerVpxUpdate, 360f); + + if (turntable.Radius <= 0f || math.abs(turntable.Speed) <= 0.0001f) { + return; + } + + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + if (ball.IsFrozen || !IsBallInRange(in ball, in turntable)) { + continue; + } + ApplyVpxCompatibleForce(ref ball, in turntable, physicsDiffTime); + } + } + } + + internal static void RefreshKinematicState(int itemId, ref TurntableState turntable, ref PhysicsState state) + { + if (turntable.IsKinematic && state.KinematicTransforms.TryGetValue(itemId, out var matrix)) { + ApplyKinematicTransform(ref turntable, in matrix); + } + } + + internal static void ApplyKinematicTransform(ref TurntableState turntable, in float4x4 matrix) + { + turntable.Position = matrix.c3.xy; + turntable.Height = matrix.c3.z; + } + + internal static void UpdateSpeed(ref TurntableState turntable, float physicsDiffTime) + { + // cvpmTurnTable semantics: the motor ramp applies whenever the motor drives + // the disc — including through a direction reversal — and the friction ramp + // applies when it coasts down + var targetSpeed = turntable.MotorOn ? turntable.TargetSpeed : 0f; + var acceleration = turntable.MotorOn ? turntable.SpinUp : turntable.SpinDown; + turntable.Speed = MoveTowards(turntable.Speed, targetSpeed, math.max(0f, acceleration) * physicsDiffTime * SecondsPerVpxUpdate); + } + + internal static void ApplyVpxCompatibleForce(ref BallState ball, in TurntableState turntable, float physicsDiffTime) + { + var delta = ball.Position.xy - turntable.Position; + var impulse = new float2(-delta.y, delta.x) * (turntable.Speed / VpxTurntableForceDivisor) * physicsDiffTime; + ball.Velocity = new float3(ball.Velocity.x + impulse.x, ball.Velocity.y + impulse.y, ball.Velocity.z); + } + + internal static bool IsBallInRange(in BallState ball, in TurntableState turntable) + { + // balls on ramps or upper playfields above the disc are not affected; VPX got + // this implicitly from the z-bounded trigger that tracked the balls + if (turntable.HeightRange > 0f && (ball.Position.z < turntable.Height || ball.Position.z > turntable.Height + turntable.HeightRange)) { + return false; + } + return math.lengthsq(ball.Position.xy - turntable.Position) <= turntable.Radius * turntable.Radius; + } + + internal static void RefreshTargetSpeed(ref TurntableState turntable) + { + turntable.TargetSpeed = (turntable.SpinClockwise ? 1f : -1f) * turntable.MaxSpeed; + } + + private static float MoveTowards(float current, float target, float maxDelta) + { + var delta = target - current; + if (math.abs(delta) <= maxDelta) { + return target; + } + return current + math.sign(delta) * maxDelta; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs.meta new file mode 100644 index 000000000..07337c535 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76b27281f48a41b7a80ad903ca1ba5d0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs new file mode 100644 index 000000000..18e1eb7df --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs @@ -0,0 +1,42 @@ +// 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.Runtime.InteropServices; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal struct TurntableState + { + internal float2 Position; + internal float Height; + internal float Radius; + internal float HeightRange; + internal float Speed; + internal float TargetSpeed; + internal float MaxSpeed; + internal float SpinUp; + internal float SpinDown; + [MarshalAs(UnmanagedType.U1)] + internal bool MotorOn; + [MarshalAs(UnmanagedType.U1)] + internal bool SpinClockwise; + [MarshalAs(UnmanagedType.U1)] + internal bool IsKinematic; + internal float RotationAngle; + internal float VisualSpeedFactor; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs.meta new file mode 100644 index 000000000..6ca26348c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 50d3de2ddfa8424eab3b6b746b9dd3d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: