diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 050ab02..ce53c38 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -192,6 +192,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt lookReceipt = null; WitnessReceipt focusReceipt = null; WitnessReceipt tractorReceipt = null; + WitnessReceipt tractorReleaseReceipt = null; WitnessReceipt actionReceipt = null; EveUnityShotReceipt observedShot = null; EveUnityCombatPresentation observedCombat = null; @@ -202,6 +203,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests float aimDotDistance = 0f; int cockpitProgressCount = 0; float tractorBeamPower = 0f; + float tractorReleasedPower = 1f; int tractorBeamParticleSystemCount = 0; try @@ -385,7 +387,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(beamPresentation.ActivationActionId, Is.Not.Empty, "The beam presentation does not name an advertised generic action."); var tractorVersion = runtime.ActiveVersion; - var tractor = host.SubmitAdvertisedActionIntent(playerId, beamPresentation.ActivationActionId); + var tractor = host.SubmitAdvertisedActionValueIntent(playerId, beamPresentation.ActivationActionId, 1f); var tractorDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < tractorDeadline && (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractor.CommandId || @@ -600,6 +602,29 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(mapCapturePath))); File.WriteAllBytes(mapCapturePath, pixels.EncodeToPNG()); Assert.That(new FileInfo(mapCapturePath).Length, Is.GreaterThan(1024)); + var tractorReleaseVersion = runtime.ActiveVersion; + var tractorRelease = host.SubmitAdvertisedActionValueIntent( + playerId, + beamPresentation.ActivationActionId, + 0f); + var tractorReleaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < tractorReleaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractorRelease.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).Single().Power > + beamPresentation.ActivationThreshold)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, tractorRelease.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, tractorReleaseVersion); + tractorReleaseReceipt = WitnessReceipt.From("beam-release", runtime.LastReceipt); + beamRenderer.RefreshNow(); + Assert.That(beamRenderer.TryGetPower(beamPresentation.Id, out tractorReleasedPower), Is.True); + Assert.That(tractorReleasedPower, Is.Zero, + "Released held input did not return the daemon-authored tractor presentation to zero."); WriteWitnessFacts( initialVersion, runtime.ActiveVersion, @@ -615,6 +640,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests lookReceipt, focusReceipt, tractorReceipt, + tractorReleaseReceipt, actionReceipt, observedShot, shieldRatioBefore, @@ -631,6 +657,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests pilotBrightPixelCount, cockpitProgressCount, tractorBeamPower, + tractorReleasedPower, tractorBeamParticleSystemCount, playerRendererFacts); } @@ -665,6 +692,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt look, WitnessReceipt targeting, WitnessReceipt tractor, + WitnessReceipt tractorRelease, WitnessReceipt action, EveUnityShotReceipt shot, float shieldRatioBefore, @@ -681,6 +709,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests int pilotBrightPixelCount, int cockpitProgressCount, float tractorBeamPower, + float tractorReleasedPower, int tractorBeamParticleSystemCount, RendererFact[] playerRendererFacts) { @@ -695,6 +724,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests aimPresentation = look != null && aimDotDistance >= 49.9f, targeting = targeting != null, beamPresentation = tractor != null && tractorBeamPower > 0.01f && tractorBeamParticleSystemCount > 0, + beamRelease = tractorRelease != null && tractorReleasedPower == 0f, action = action != null, combatPresentation = shot != null, shotId = shot?.ShotId ?? "", @@ -721,13 +751,14 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests pilotBrightPixelCount = pilotBrightPixelCount, cockpitProgressCount = cockpitProgressCount, tractorBeamPower = tractorBeamPower, + tractorReleasedPower = tractorReleasedPower, tractorBeamParticleSystemCount = tractorBeamParticleSystemCount, playerRenderers = playerRendererFacts, mapChannelRendererCount = mapChannelRendererCount, mapChangedPixels = mapChangedPixels, pilotCameraExcludesMapChannel = pilotCameraExcludesMapChannel, mapCameraIncludesMapChannel = mapCameraIncludesMapChannel, - receipts = new[] { movement, look, targeting, tractor, action } + receipts = new[] { movement, look, targeting, tractor, tractorRelease, action } }; Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); File.WriteAllText(path, JsonUtility.ToJson(document, true)); @@ -743,6 +774,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool aimPresentation; public bool targeting; public bool beamPresentation; + public bool beamRelease; public bool action; public bool combatPresentation; public string shotId; @@ -769,6 +801,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public int pilotBrightPixelCount; public int cockpitProgressCount; public float tractorBeamPower; + public float tractorReleasedPower; public int tractorBeamParticleSystemCount; public RendererFact[] playerRenderers; public int mapChannelRendererCount; diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 7a5b662..1f1ebaf 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -50,7 +50,7 @@ "dependencies": { "org.gamecult.cultlib": "1.0.13", "org.gamecult.eve.plugin-fields": "0.1.1", - "org.gamecult.eve.surface": "0.2.1", + "org.gamecult.eve.surface": "0.2.2", "com.unity.mathematics": "1.3.2", "com.unity.modules.particlesystem": "1.0.0" } diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs index 356edee..d3fdb61 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs @@ -29,6 +29,14 @@ namespace GameCult.Eve.Surface [Key(5)] public string Availability { get; set; } = "available"; [Key(6)] public string SourceRef { get; set; } = ""; [Key(7)] public Dictionary Payload { get; set; } = new Dictionary(StringComparer.Ordinal); + [Key(8)] public EveInputValueDocument? InputValue { get; set; } + } + + [MessagePackObject] + public sealed class EveInputValueDocument + { + [Key(0)] public string Model { get; set; } = ""; + [Key(1)] public string PayloadKey { get; set; } = ""; } [MessagePackObject] diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index 0da4785..87896d6 100644 --- a/packages/org.gamecult.eve.surface/package.json +++ b/packages/org.gamecult.eve.surface/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.surface", "displayName": "Eve Surface Contract", - "version": "0.2.1", + "version": "0.2.2", "unity": "2021.3", "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", "dependencies": { diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs new file mode 100644 index 0000000..33d5748 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityAdvertisedInputAction + { + public const string ButtonHoldValueModel = "button-hold.v1"; + public const string AxisValueModel = "axis.v1"; + + private readonly EveInputActionDocument _action; + + private EveUnityAdvertisedInputAction(EveInputActionDocument action) + { + _action = action; + } + + public string ActionId => _action.ActionId ?? ""; + public string Operation => _action.Operation ?? ""; + public string Availability => _action.Availability ?? ""; + public string InputValueModel => _action.InputValue?.Model ?? ""; + public string InputValuePayloadKey => _action.InputValue?.PayloadKey ?? ""; + public bool IsButtonHold => string.Equals(InputValueModel, ButtonHoldValueModel, StringComparison.Ordinal); + + public static EveUnityAdvertisedInputAction Resolve(EveInputCapabilityDocument? capability, string actionId) + { + if (capability == null) + throw new InvalidOperationException("The provider did not advertise an input capability."); + var action = (capability.Actions ?? Array.Empty()).FirstOrDefault(candidate => + string.Equals(candidate?.ActionId, actionId, StringComparison.Ordinal)); + if (action == null || string.IsNullOrWhiteSpace(action.Operation)) + throw new InvalidOperationException($"Input action '{actionId}' is not advertised with an operation."); + if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Input action '{actionId}' is not available."); + return new EveUnityAdvertisedInputAction(action); + } + + public IReadOnlyDictionary BuildPayload(string entityId, float? inputValue = null) + { + var payload = _action.Payload == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(_action.Payload, StringComparer.Ordinal); + payload["entityId"] = entityId ?? ""; + payload["actionId"] = ActionId; + + if (inputValue.HasValue) + { + if (float.IsNaN(inputValue.Value) || float.IsInfinity(inputValue.Value)) + throw new ArgumentOutOfRangeException(nameof(inputValue), "Input action value must be finite."); + if (string.IsNullOrWhiteSpace(InputValueModel) || string.IsNullOrWhiteSpace(InputValuePayloadKey)) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a value payload."); + payload[InputValuePayloadKey] = inputValue.Value.ToString("R", CultureInfo.InvariantCulture); + } + + return payload; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs.meta new file mode 100644 index 0000000..a2236f2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7555b03e7f9451aa891e92958b845c5 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index f29bf73..4dfdf43 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -203,21 +203,21 @@ namespace GameCult.Eve.UnityScene string actionId, DateTimeOffset? issuedAt = null) { - var capability = InputCapability ?? - throw new InvalidOperationException("The provider did not advertise an input capability."); - var action = Array.Find(capability.Actions ?? Array.Empty(), candidate => - string.Equals(candidate?.ActionId, actionId, StringComparison.Ordinal)); - if (action == null || string.IsNullOrWhiteSpace(action.Operation)) - throw new InvalidOperationException($"Input action '{actionId}' is not advertised with an operation."); - if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Input action '{actionId}' is not available."); + var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + return RequireRuntime().SubmitCommandIntent(action.Operation, action.BuildPayload(entityId), issuedAt); + } - var payload = action.Payload == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(action.Payload, StringComparer.Ordinal); - payload["entityId"] = entityId ?? ""; - payload["actionId"] = action.ActionId; - return RequireRuntime().SubmitCommandIntent(action.Operation, payload, issuedAt); + public EveSurfaceCommandRequest SubmitAdvertisedActionValueIntent( + string entityId, + string actionId, + float inputValue, + DateTimeOffset? issuedAt = null) + { + var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + return RequireRuntime().SubmitCommandIntent( + action.Operation, + action.BuildPayload(entityId, inputValue), + issuedAt); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs index 62f1d24..1dbca9c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using GameCult.Eve.Surface; using UnityEngine; @@ -32,6 +33,10 @@ namespace GameCult.Eve.UnityScene private bool _hasLookYaw; private float _pendingLookHorizontal; private string _lookOwnerKey = ""; + private readonly Dictionary _heldBindings = + new Dictionary(StringComparer.Ordinal); + + public Func? DigitalControlReader { get; set; } public EveUnityPlayableWorldClientHost? Host { @@ -39,6 +44,7 @@ namespace GameCult.Eve.UnityScene set { if (ReferenceEquals(host, value)) return; + ReleaseHeldActions(); host = value; ResetLookState(); } @@ -105,6 +111,21 @@ namespace GameCult.Eve.UnityScene resolvedActionId); } + public EveSurfaceCommandRequest? SubmitActionValue( + string actionId, + float inputValue, + DateTimeOffset? issuedAt = null) + { + var resolvedHost = ResolveHost(); + if (resolvedHost?.ActiveWorld == null || string.IsNullOrWhiteSpace(actionId)) + return null; + return resolvedHost.SubmitAdvertisedActionValueIntent( + resolvedHost.ActiveWorld.PlayerEntityId, + actionId, + inputValue, + issuedAt); + } + public EveSurfaceCommandRequest? SubmitLookInput( float horizontalDelta, DateTimeOffset? issuedAt = null) @@ -178,11 +199,98 @@ namespace GameCult.Eve.UnityScene _nextCommandAt = Time.unscaledTime + Math.Max(0.01f, commandIntervalSeconds); SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis)); SubmitPendingLookInput(); + SubmitChangedHeldActions(); if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton)) SubmitPrimaryAction(); } + private void SubmitChangedHeldActions() + { + var resolvedHost = ResolveHost(); + var capability = resolvedHost?.InputCapability; + if (resolvedHost?.ActiveWorld == null || capability == null) + return; + + var profile = (capability.DefaultProfiles ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal)); + var retained = new HashSet(StringComparer.Ordinal); + foreach (var binding in profile?.Bindings ?? Array.Empty()) + { + var action = (capability.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => string.Equals(candidate.ActionId, binding.ActionId, StringComparison.Ordinal)); + if (action?.InputValue == null || + !string.Equals(action.InputValue.Model, EveUnityAdvertisedInputAction.ButtonHoldValueModel, StringComparison.Ordinal) || + !TryReadDigitalGesture(binding.Gesture, out var pressed)) + continue; + + var stateKey = string.IsNullOrWhiteSpace(binding.BindingId) ? binding.ActionId : binding.BindingId; + retained.Add(stateKey); + if (!_heldBindings.TryGetValue(stateKey, out var previous)) + { + _heldBindings[stateKey] = new HeldBindingState(binding.ActionId, pressed); + if (pressed) SubmitActionValue(binding.ActionId, 1f); + continue; + } + if (previous.Pressed == pressed) continue; + _heldBindings[stateKey] = new HeldBindingState(binding.ActionId, pressed); + SubmitActionValue(binding.ActionId, pressed ? 1f : 0f); + } + + foreach (var stale in _heldBindings.Keys.Where(key => !retained.Contains(key)).ToArray()) + { + var previous = _heldBindings[stale]; + if (previous.Pressed) TrySubmitRelease(previous.ActionId); + _heldBindings.Remove(stale); + } + } + + private bool TryReadDigitalGesture(EveInputGestureDocument? gesture, out bool pressed) + { + pressed = false; + if (gesture == null || + !(string.Equals(gesture.Kind, "direct", StringComparison.Ordinal) || + string.Equals(gesture.Kind, "chord", StringComparison.Ordinal))) + return false; + var controls = gesture.Controls ?? Array.Empty(); + if (controls.Length == 0) return false; + foreach (var control in controls) + { + var value = DigitalControlReader?.Invoke(control) ?? ReadStandardDigitalControl(control); + if (!value.HasValue) return false; + if (!value.Value) return true; + } + pressed = true; + return true; + } + + private static bool? ReadStandardDigitalControl(string control) + { + if (string.Equals(control, "mouse.primary", StringComparison.Ordinal)) return Input.GetMouseButton(0); + if (string.Equals(control, "mouse.secondary", StringComparison.Ordinal)) return Input.GetMouseButton(1); + if (string.Equals(control, "mouse.middle", StringComparison.Ordinal)) return Input.GetMouseButton(2); + const string keyboardPrefix = "keyboard."; + if (control != null && control.StartsWith(keyboardPrefix, StringComparison.Ordinal) && + Enum.TryParse(control.Substring(keyboardPrefix.Length), true, out KeyCode key)) + return Input.GetKey(key); + return null; + } + + private void OnDisable() => ReleaseHeldActions(); + + private void ReleaseHeldActions() + { + foreach (var state in _heldBindings.Values) + if (state.Pressed) TrySubmitRelease(state.ActionId); + _heldBindings.Clear(); + } + + private void TrySubmitRelease(string actionId) + { + try { SubmitActionValue(actionId, 0f); } + catch (InvalidOperationException) { } + } + private EveUnityPlayableWorldClientHost? ResolveHost() { if (host != null) @@ -207,6 +315,18 @@ namespace GameCult.Eve.UnityScene _pendingLookHorizontal = 0f; _lookOwnerKey = ""; } + + private readonly struct HeldBindingState + { + public HeldBindingState(string actionId, bool pressed) + { + ActionId = actionId ?? ""; + Pressed = pressed; + } + + public string ActionId { get; } + public bool Pressed { get; } + } } public readonly struct EveUnityPlayableWorldLookDirection diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs new file mode 100644 index 0000000..8174a80 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityAdvertisedInputActionTests + { + [Test] + public void HeldValueOverridesAdvertisedPayloadWithoutMutatingCapability() + { + var sourcePayload = new Dictionary(StringComparer.Ordinal) + { + ["scalarValue"] = "0" + }; + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.scoop", + Operation = "aetheria.daemon.commands.SetTractorPower", + Availability = "available", + Payload = sourcePayload, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ButtonHoldValueModel, + PayloadKey = "scalarValue" + } + } + } + }, "pilot.scoop"); + + var pressed = action.BuildPayload("entity.4", 1f); + var released = action.BuildPayload("entity.4", 0f); + + Assert.That(action.IsButtonHold, Is.True); + Assert.That(pressed["scalarValue"], Is.EqualTo("1")); + Assert.That(released["scalarValue"], Is.EqualTo("0")); + Assert.That(pressed["entityId"], Is.EqualTo("entity.4")); + Assert.That(pressed["actionId"], Is.EqualTo("pilot.scoop")); + Assert.That(sourcePayload["scalarValue"], Is.EqualTo("0")); + } + + [Test] + public void ValueFailsClosedWhenActionDoesNotAdvertiseValueContract() + { + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.dock", + Operation = "aetheria.daemon.commands.DockNearest", + Availability = "available" + } + } + }, "pilot.dock"); + + Assert.Throws(() => action.BuildPayload("entity.4", 1f)); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs.meta new file mode 100644 index 0000000..5f325ab --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a60138c42bf44c2ba38309b21a886c93 diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 6997c1c..4882abb 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,13 +1,13 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.30", + "version": "0.3.31", "unity": "2021.3", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { "org.gamecult.cultlib": "1.0.13", "org.gamecult.eve.plugin-fields": "0.1.1", - "org.gamecult.eve.surface": "0.2.1", + "org.gamecult.eve.surface": "0.2.2", "com.unity.mathematics": "1.3.2", "com.unity.modules.particlesystem": "1.0.0" },