From e08fa08335f99e9edddeb706912eecfad07cb281 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 17 Jul 2026 05:24:08 +0100 Subject: [PATCH] Lower generic scalar actions and action bar --- .../Runtime/EveInputCapabilityDocument.cs | 7 + .../org.gamecult.eve.surface/package.json | 2 +- .../org.gamecult.eve.unity-scene/README.md | 8 + .../Runtime/EveUnityAdvertisedInputAction.cs | 39 ++- .../Runtime/EveUnityInputActionBar.cs | 306 ++++++++++++++++++ .../Runtime/EveUnityInputActionBar.cs.meta | 2 + .../EveUnityPlayableWorldClientHost.cs | 21 +- .../Runtime/EveUnityThermalHudSink.cs | 20 +- .../EveUnityAdvertisedInputActionTests.cs | 73 +++++ .../EveUnitySceneSurfaceLowererTests.cs | 115 +++++++ .../org.gamecult.eve.unity-scene/package.json | 4 +- 11 files changed, 583 insertions(+), 14 deletions(-) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs.meta diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs index 0e38563..ed8b486 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs @@ -30,6 +30,8 @@ namespace GameCult.Eve.Surface [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; } + [Key(9)] public string IconRef { get; set; } = ""; + [Key(10)] public bool ActionBar { get; set; } } [MessagePackObject] @@ -38,6 +40,11 @@ namespace GameCult.Eve.Surface [Key(0)] public string Model { get; set; } = ""; [Key(1)] public string PayloadKey { get; set; } = ""; [Key(2)] public string[] PayloadKeys { get; set; } = Array.Empty(); + [Key(3)] public double? CurrentValue { get; set; } + [Key(4)] public double? MinimumValue { get; set; } + [Key(5)] public double? MaximumValue { get; set; } + [Key(6)] public double? StepValue { get; set; } + [Key(7)] public string Unit { get; set; } = ""; } [MessagePackObject] diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index 4ece28c..36fd3b6 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.3", + "version": "0.2.4", "unity": "2021.3", "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", "dependencies": { diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 939c687..1c2dce7 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -22,6 +22,14 @@ active world camera's normalized world-space forward direction into the three advertised payload keys. Providers own what that ray means; EveUnity never selects targets or imports provider gameplay policy. +`EveUnityInputActionBar` lowers provider suggestions and profile-visible actions +from that same catalog. It resolves `iconRef` through the provider asset +manifest, disables unavailable actions, preserves press/release behavior for +held controls, samples the active camera for view-direction controls, and +submits `scalar.v1` values from an explicit numeric field. Its named UI Toolkit +elements and classes are presentation levers; the component never imports a +provider assembly or mutates provider-owned current values optimistically. + `IEveUnityPresentedEntityRegistry` exposes each committed SoA generation as immutable entity facts plus presentation transforms, addressable by Eve entity id or source index. Camera, selection, and provider UI adapters consume that diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs index 9976441..8012fcc 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs @@ -12,6 +12,7 @@ namespace GameCult.Eve.UnityScene { public const string ButtonHoldValueModel = "button-hold.v1"; public const string AxisValueModel = "axis.v1"; + public const string ScalarValueModel = "scalar.v1"; public const string ViewDirectionValueModel = "view-direction.v1"; private readonly EveInputActionDocument _action; @@ -27,10 +28,19 @@ namespace GameCult.Eve.UnityScene public string InputValueModel => _action.InputValue?.Model ?? ""; public string InputValuePayloadKey => _action.InputValue?.PayloadKey ?? ""; public IReadOnlyList InputValuePayloadKeys => _action.InputValue?.PayloadKeys ?? Array.Empty(); + public double? CurrentValue => _action.InputValue?.CurrentValue; + public double? MinimumValue => _action.InputValue?.MinimumValue; + public double? MaximumValue => _action.InputValue?.MaximumValue; + public double? StepValue => _action.InputValue?.StepValue; + public string Unit => _action.InputValue?.Unit ?? ""; public bool IsButtonHold => string.Equals(InputValueModel, ButtonHoldValueModel, StringComparison.Ordinal); + public bool IsScalar => string.Equals(InputValueModel, ScalarValueModel, StringComparison.Ordinal); public bool IsViewDirection => string.Equals(InputValueModel, ViewDirectionValueModel, StringComparison.Ordinal); - public static EveUnityAdvertisedInputAction Resolve(EveInputCapabilityDocument? capability, string actionId) + public static EveUnityAdvertisedInputAction Resolve( + EveInputCapabilityDocument? capability, + string actionId, + bool requireAvailable = true) { if (capability == null) throw new InvalidOperationException("The provider did not advertise an input capability."); @@ -38,7 +48,7 @@ namespace GameCult.Eve.UnityScene 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)) + if (requireAvailable && !string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Input action '{actionId}' is not available."); return new EveUnityAdvertisedInputAction(action); } @@ -90,6 +100,31 @@ namespace GameCult.Eve.UnityScene return payload; } + public IReadOnlyDictionary BuildScalarPayload(string entityId, double value) + { + if (!IsScalar) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a scalar value."); + if (double.IsNaN(value) || double.IsInfinity(value)) + throw new ArgumentOutOfRangeException(nameof(value), "Scalar input value must be finite."); + if (!CurrentValue.HasValue || double.IsNaN(CurrentValue.Value) || double.IsInfinity(CurrentValue.Value)) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a finite current scalar value."); + if (MinimumValue.HasValue && value < MinimumValue.Value) + throw new ArgumentOutOfRangeException(nameof(value), $"Scalar input value is below the advertised minimum {MinimumValue.Value}."); + if (MaximumValue.HasValue && value > MaximumValue.Value) + throw new ArgumentOutOfRangeException(nameof(value), $"Scalar input value is above the advertised maximum {MaximumValue.Value}."); + if (MinimumValue.HasValue && MaximumValue.HasValue && MinimumValue.Value > MaximumValue.Value) + throw new InvalidOperationException($"Input action '{ActionId}' advertises an inverted scalar range."); + if (StepValue.HasValue && (!IsFinite(StepValue.Value) || StepValue.Value <= 0)) + throw new InvalidOperationException($"Input action '{ActionId}' advertises an invalid scalar step."); + if (string.IsNullOrWhiteSpace(InputValuePayloadKey)) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a scalar payload key."); + + var payload = BuildPayload(entityId).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + payload[InputValuePayloadKey] = value.ToString("R", CultureInfo.InvariantCulture); + return payload; + } + private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs new file mode 100644 index 0000000..b9baf98 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Eve.Surface; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityInputActionBar : MonoBehaviour + { + private readonly Dictionary _rows = + new Dictionary(StringComparer.Ordinal); + private readonly HashSet _pressedHolds = new HashSet(StringComparer.Ordinal); + private EveUnityPlayableWorldClientHost? _host; + private UIDocument? _document; + private VisualElement? _root; + private string _structureKey = ""; + private float _nextRefreshAt; + + public VisualElement? Root => _root; + public IReadOnlyCollection PresentedActionIds => _rows.Keys; + + private void Awake() => EnsureBuilt(); + + public void Bind(EveUnityPlayableWorldClientHost host) + { + _host = host != null ? host : throw new ArgumentNullException(nameof(host)); + EnsureBuilt(); + RefreshNow(); + } + + private void Update() + { + if (Time.unscaledTime < _nextRefreshAt) return; + _nextRefreshAt = Time.unscaledTime + 0.2f; + RefreshNow(); + } + + public void RefreshNow() + { + EnsureBuilt(); + var capability = _host?.InputCapability; + var actions = SelectActions(capability).ToArray(); + var structureKey = string.Join("\n", actions.Select(action => + $"{action.ActionId}\t{action.Label}\t{action.IconRef}\t{action.InputValue?.Model}")); + if (!string.Equals(_structureKey, structureKey, StringComparison.Ordinal)) + { + ReleaseAllHolds(); + Rebuild(actions); + _structureKey = structureKey; + } + foreach (var action in actions) + if (_rows.TryGetValue(action.ActionId ?? "", out var row)) + Apply(row, action); + if (_root != null) + _root.style.display = actions.Length == 0 ? DisplayStyle.None : DisplayStyle.Flex; + } + + public static IReadOnlyList SelectActions(EveInputCapabilityDocument? capability) + { + if (capability == null) return Array.Empty(); + var actions = (capability.Actions ?? Array.Empty()) + .Where(action => action != null && !string.IsNullOrWhiteSpace(action.ActionId)) + .ToDictionary(action => action.ActionId, StringComparer.Ordinal); + var selected = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var profile = (capability.DefaultProfiles ?? Array.Empty()) + .FirstOrDefault(candidate => string.Equals(candidate.DeviceClass, "keyboard-mouse", StringComparison.Ordinal)) + ?? (capability.DefaultProfiles ?? Array.Empty()).FirstOrDefault(); + foreach (var binding in profile?.Bindings ?? Array.Empty()) + if (binding.ActionBar && actions.TryGetValue(binding.ActionId ?? "", out var action) && seen.Add(action.ActionId)) + selected.Add(action); + foreach (var action in actions.Values.OrderBy(candidate => candidate.ActionId, StringComparer.Ordinal)) + if (action.ActionBar && seen.Add(action.ActionId)) + selected.Add(action); + return selected; + } + + public void SubmitPerformed(string actionId) + { + var host = RequireHost(); + var action = EveUnityAdvertisedInputAction.Resolve(host.InputCapability, actionId); + if (action.IsViewDirection) + { + var view = host.ActiveCameraTransform; + if (view == null || view.forward.sqrMagnitude <= 0.000001f) return; + var direction = view.forward.normalized; + host.SubmitAdvertisedActionViewDirectionIntent( + RequireEntityId(host), actionId, direction.x, direction.y, direction.z); + return; + } + host.SubmitAdvertisedActionIntent(RequireEntityId(host), actionId); + } + + public void SubmitHold(string actionId, bool active) + { + var host = RequireHost(); + host.SubmitAdvertisedActionValueIntent(RequireEntityId(host), actionId, active ? 1f : 0f); + } + + public void SubmitScalar(string actionId, string value) + { + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) || + double.IsNaN(parsed) || double.IsInfinity(parsed)) + throw new FormatException($"'{value}' is not a finite scalar value."); + var host = RequireHost(); + host.SubmitAdvertisedActionScalarIntent(RequireEntityId(host), actionId, parsed); + } + + private void Rebuild(IReadOnlyList actions) + { + _root!.Clear(); + _rows.Clear(); + _pressedHolds.Clear(); + foreach (var action in actions) + { + var row = BuildRow(action); + _rows[action.ActionId] = row; + _root.Add(row.Root); + } + } + + private void OnDisable() => ReleaseAllHolds(); + + private void ReleaseAllHolds() + { + foreach (var actionId in _pressedHolds.ToArray()) + { + try { SubmitHold(actionId, false); } + catch (InvalidOperationException) { } + } + _pressedHolds.Clear(); + } + + private ActionRow BuildRow(EveInputActionDocument action) + { + var root = new VisualElement { name = "eve-action-slot-" + SafeName(action.ActionId) }; + root.AddToClassList("eve-input-action-slot"); + root.style.width = 112; + root.style.minHeight = 72; + root.style.paddingLeft = root.style.paddingRight = 6; + root.style.paddingTop = root.style.paddingBottom = 5; + root.style.marginLeft = root.style.marginRight = 3; + root.style.backgroundColor = new Color(0.025f, 0.03f, 0.035f, 0.88f); + root.style.borderLeftWidth = root.style.borderRightWidth = 1; + root.style.borderTopWidth = root.style.borderBottomWidth = 1; + root.style.borderLeftColor = root.style.borderRightColor = new Color(0.35f, 0.42f, 0.46f, 0.8f); + root.style.borderTopColor = root.style.borderBottomColor = new Color(0.35f, 0.42f, 0.46f, 0.8f); + + var icon = new Image { name = "icon", scaleMode = ScaleMode.ScaleToFit }; + icon.AddToClassList("eve-input-action-icon"); + icon.style.height = 28; + root.Add(icon); + + var label = new Label(action.Label ?? action.ActionId) { name = "label" }; + label.AddToClassList("eve-input-action-label"); + label.style.fontSize = 9; + label.style.color = Color.white; + label.style.unityTextAlign = TextAnchor.MiddleCenter; + label.style.whiteSpace = WhiteSpace.Normal; + root.Add(label); + + TextField? scalar = null; + Button? trigger = null; + if (string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ScalarValueModel, StringComparison.Ordinal)) + { + var controls = new VisualElement(); + controls.style.flexDirection = FlexDirection.Row; + scalar = new TextField { name = "scalar" }; + scalar.style.flexGrow = 1; + scalar.RegisterCallback(evt => + { + if (evt.keyCode != KeyCode.Return && evt.keyCode != KeyCode.KeypadEnter) return; + SubmitScalar(action.ActionId, scalar.value); + evt.StopPropagation(); + }); + controls.Add(scalar); + trigger = new Button(() => SubmitScalar(action.ActionId, scalar.value)) { text = "Set", name = "submit" }; + controls.Add(trigger); + root.Add(controls); + } + else + { + trigger = new Button { text = "Use", name = "trigger" }; + if (string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ButtonHoldValueModel, StringComparison.Ordinal)) + { + trigger.RegisterCallback(evt => + { + if (!_pressedHolds.Add(action.ActionId)) return; + trigger.CapturePointer(evt.pointerId); + SubmitHold(action.ActionId, true); + }); + trigger.RegisterCallback(evt => + { + ReleaseHold(action.ActionId); + if (trigger.HasPointerCapture(evt.pointerId)) trigger.ReleasePointer(evt.pointerId); + }); + trigger.RegisterCallback(_ => ReleaseHold(action.ActionId)); + } + else + { + trigger.clicked += () => SubmitPerformed(action.ActionId); + } + root.Add(trigger); + } + return new ActionRow(root, icon, label, trigger, scalar); + } + + private void Apply(ActionRow row, EveInputActionDocument action) + { + row.Label.text = action.Label ?? action.ActionId; + var available = string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase); + row.Root.SetEnabled(available); + row.Root.style.opacity = available ? 1f : 0.45f; + if (row.Scalar != null && action.InputValue?.CurrentValue is double current && + row.Scalar.focusController?.focusedElement != row.Scalar) + { + row.Scalar.SetValueWithoutNotify(current.ToString("R", CultureInfo.InvariantCulture)); + row.Scalar.tooltip = string.IsNullOrWhiteSpace(action.InputValue.Unit) + ? "" : action.InputValue.Unit; + } + if (!string.IsNullOrWhiteSpace(action.IconRef) && _host?.NativeAssetProvider != null) + { + var texture = _host.NativeAssetProvider.ResolveAsset( + new EveUnityPlayableWorldAssetBinding(action.IconRef, "", "provider-asset-ref"), + typeof(Texture2D)) as Texture2D; + row.Icon.image = texture; + } + else row.Icon.image = null; + } + + private void ReleaseHold(string actionId) + { + if (!_pressedHolds.Remove(actionId)) return; + SubmitHold(actionId, false); + } + + private EveUnityPlayableWorldClientHost RequireHost() => + _host ?? throw new InvalidOperationException("The generic input action bar is not bound to a playable-world host."); + + private static string RequireEntityId(EveUnityPlayableWorldClientHost host) => + host.ActiveWorld?.PlayerEntityId ?? throw new InvalidOperationException("The playable world has no active player entity."); + + private void EnsureBuilt() + { + if (_root != null) + { + AttachToDocument(); + return; + } + _document = GetComponent(); + if (_document == null) + { + var panel = ScriptableObject.CreateInstance(); + panel.name = "Eve playable-world HUD panel"; + panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; + panel.referenceResolution = new Vector2Int(1920, 1080); + panel.match = 0.5f; + _document = gameObject.AddComponent(); + _document.panelSettings = panel; + _document.sortingOrder = 100; + } + _root = new VisualElement { name = "eve-input-action-bar" }; + _root.AddToClassList("eve-input-action-bar"); + _root.style.position = Position.Absolute; + _root.style.left = Length.Percent(50); + _root.style.bottom = 18; + _root.style.translate = new Translate(Length.Percent(-50), 0); + _root.style.flexDirection = FlexDirection.Row; + _root.style.alignItems = Align.FlexEnd; + AttachToDocument(); + } + + private void AttachToDocument() + { + var documentRoot = _document?.rootVisualElement; + if (documentRoot != null && _root != null && _root.parent == null) + documentRoot.Add(_root); + } + + private static string SafeName(string value) => + new string((value ?? "").Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray()); + + private sealed class ActionRow + { + public ActionRow(VisualElement root, Image icon, Label label, Button? trigger, TextField? scalar) + { + Root = root; + Icon = icon; + Label = label; + Trigger = trigger; + Scalar = scalar; + } + + public VisualElement Root { get; } + public Image Icon { get; } + public Label Label { get; } + public Button? Trigger { get; } + public TextField? Scalar { get; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs.meta new file mode 100644 index 0000000..36502a1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bb62601554a846f39e46a6969c10eff7 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index 8390830..678b4a9 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -118,6 +118,9 @@ namespace GameCult.Eve.UnityScene var fieldParticles = GetComponent(); if (fieldParticles == null) fieldParticles = gameObject.AddComponent(); fieldParticles.Bind(this, providerSurfaceDocuments as IEveUnityFieldsSplatsDocumentSource); + var actionBar = GetComponent(); + if (actionBar == null) actionBar = gameObject.AddComponent(); + actionBar.Bind(this); var presentation = Runtime.Connect(); ConnectionEpoch++; @@ -220,7 +223,10 @@ namespace GameCult.Eve.UnityScene float inputValue, DateTimeOffset? issuedAt = null) { - var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + var action = EveUnityAdvertisedInputAction.Resolve( + InputCapability, + actionId, + requireAvailable: inputValue != 0f); return RequireRuntime().SubmitCommandIntent( action.Operation, action.BuildPayload(entityId, inputValue), @@ -242,6 +248,19 @@ namespace GameCult.Eve.UnityScene issuedAt); } + public EveSurfaceCommandRequest SubmitAdvertisedActionScalarIntent( + string entityId, + string actionId, + double value, + DateTimeOffset? issuedAt = null) + { + var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + return RequireRuntime().SubmitCommandIntent( + action.Operation, + action.BuildScalarPayload(entityId, value), + issuedAt); + } + public void Disconnect() { if (_cameraOwner is EveUnityPlayableWorldCameraRig rig) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs index ea9919a..dba6538 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs @@ -30,14 +30,18 @@ namespace GameCult.Eve.UnityScene AttachToDocument(); return; } - var panel = ScriptableObject.CreateInstance(); - panel.name = "Eve thermal HUD panel"; - panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; - panel.referenceResolution = new Vector2Int(1920, 1080); - panel.match = 0.5f; - _document = gameObject.AddComponent(); - _document.panelSettings = panel; - _document.sortingOrder = 100; + _document = GetComponent(); + if (_document == null) + { + var panel = ScriptableObject.CreateInstance(); + panel.name = "Eve playable-world HUD panel"; + panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; + panel.referenceResolution = new Vector2Int(1920, 1080); + panel.match = 0.5f; + _document = gameObject.AddComponent(); + _document.panelSettings = panel; + _document.sortingOrder = 100; + } _root = new VisualElement(); Build(_root); AttachToDocument(); diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs index 7f089b3..f4d4190 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs @@ -65,6 +65,34 @@ namespace GameCult.Eve.UnityScene.Tests Assert.Throws(() => action.BuildPayload("entity.4", 1f)); } + [Test] + public void UnavailableActionCanOnlyBeResolvedForReleaseCleanup() + { + var capability = new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.scoop", + Operation = "provider.commands.SetPower", + Availability = "unavailable", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ButtonHoldValueModel, + PayloadKey = "active" + } + } + } + }; + + Assert.Throws(() => + EveUnityAdvertisedInputAction.Resolve(capability, "pilot.scoop")); + Assert.DoesNotThrow(() => + EveUnityAdvertisedInputAction.Resolve(capability, "pilot.scoop", requireAvailable: false) + .BuildPayload("entity.4", 0)); + } + [Test] public void ViewDirectionUsesAdvertisedKeysAndNormalizesWithoutMutatingCapability() { @@ -145,5 +173,50 @@ namespace GameCult.Eve.UnityScene.Tests }, "pilot.target-reticle"); Assert.Throws(() => action.BuildViewDirectionPayload("entity.4", 0f, 0f, 0f)); } + + [Test] + public void ScalarUsesAdvertisedStateAndRangeWithoutMutatingCapability() + { + var sourcePayload = new Dictionary(StringComparer.Ordinal) + { + ["scalarValue"] = "300", + ["equipmentIndex"] = "2" + }; + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "equipment.2.temperature", + Operation = "provider.commands.SetTemperature", + Availability = "available", + Payload = sourcePayload, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ScalarValueModel, + PayloadKey = "scalarValue", + CurrentValue = 300, + MinimumValue = 100, + MaximumValue = 600, + StepValue = 5, + Unit = "kelvin" + } + } + } + }, "equipment.2.temperature"); + + var payload = action.BuildScalarPayload("entity.4", 425.5); + + Assert.That(action.IsScalar, Is.True); + Assert.That(action.CurrentValue, Is.EqualTo(300)); + Assert.That(action.Unit, Is.EqualTo("kelvin")); + Assert.That(payload["scalarValue"], Is.EqualTo("425.5")); + Assert.That(payload["equipmentIndex"], Is.EqualTo("2")); + Assert.That(sourcePayload["scalarValue"], Is.EqualTo("300")); + Assert.Throws(() => action.BuildScalarPayload("entity.4", 99)); + Assert.Throws(() => action.BuildScalarPayload("entity.4", 601)); + Assert.Throws(() => action.BuildScalarPayload("entity.4", double.NaN)); + } } } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index d738875..3d0d373 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1216,6 +1216,121 @@ namespace GameCult.Eve.UnityScene.Tests } } + [Test] + public void GenericActionBarSelectsSuggestionsAndSubmitsScalarThroughAdvertisedOperation() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + hostObject.SetActive(false); + + try + { + var capability = new EveInputCapabilityDocument + { + ProviderId = "generic-provider", + CapabilityId = "pilot.input", + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "equipment.0.temperature", + Label = "Target Temperature", + Operation = "generic.commands.SetTemperature", + Availability = "available", + ActionBar = true, + IconRef = "item.thermostat.icon", + Payload = new Dictionary(StringComparer.Ordinal) + { + ["equipmentIndex"] = "0", + ["behaviorIndex"] = "3" + }, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ScalarValueModel, + PayloadKey = "scalarValue", + CurrentValue = 300, + Unit = "kelvin" + } + }, + new EveInputActionDocument + { + ActionId = "pilot.interact", + Label = "Interact", + Operation = "generic.commands.Interact", + Availability = "available" + }, + new EveInputActionDocument + { + ActionId = "pilot.hidden", + Label = "Hidden", + Operation = "generic.commands.Hidden", + Availability = "available" + } + }, + DefaultProfiles = new[] + { + new EveInputProfileDocument + { + ProfileId = "keyboard-mouse", + DeviceClass = "keyboard-mouse", + Bindings = new[] + { + new EveInputBindingDocument + { + BindingId = "interact.f", + ActionId = "pilot.interact", + ActionBar = true, + Gesture = new EveInputGestureDocument + { + Kind = "direct", + Controls = new[] { "keyboard.f" } + } + } + } + } + } + }; + Assert.That(EveUnityInputActionBar.SelectActions(capability) + .Select(action => action.ActionId), + Is.EqualTo(new[] { "pilot.interact", "equipment.0.temperature" })); + + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://generic/eve/surfaces/game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://generic/assets/manifest", + Array.Empty(), + "generic-provider"), + capability); + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider); + host.Connect(); + var bar = hostObject.GetComponent(); + Assert.That(bar, Is.Not.Null); + bar!.RefreshNow(); + Assert.That(bar.PresentedActionIds, + Is.EquivalentTo(new[] { "pilot.interact", "equipment.0.temperature" })); + + bar.SubmitScalar("equipment.0.temperature", "425.5"); + + var request = provider.Submitted.Single(); + Assert.That(request.Payload.GetString("commandId"), Is.EqualTo("generic.commands.SetTemperature")); + Assert.That(request.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + Assert.That(request.Payload.GetString("actionId"), Is.EqualTo("equipment.0.temperature")); + Assert.That(request.Payload.GetString("equipmentIndex"), Is.EqualTo("0")); + Assert.That(request.Payload.GetDouble("scalarValue", 0), Is.EqualTo(425.5).Within(0.000001)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + [Test] public void PlayableWorldCameraRigFollowsAdvertisedPlayerEntityWithoutProviderTypes() { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 48aa21e..f601fcc 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.64", + "version": "0.3.65", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { "org.gamecult.cultlib": "1.0.15", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.3", + "org.gamecult.eve.surface": "0.2.4", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0"