From 7647d37445cf39447cb607111e215f627db56c80 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 17 Jul 2026 04:57:39 +0100 Subject: [PATCH] Lower advertised view-direction inputs --- .../Runtime/EveInputCapabilityDocument.cs | 1 + .../org.gamecult.eve.surface/package.json | 13 +- .../org.gamecult.eve.unity-scene/README.md | 7 ++ .../Runtime/EveUnityAdvertisedInputAction.cs | 32 +++++ .../EveUnityPlayableWorldClientHost.cs | 15 +++ .../EveUnityPlayableWorldInputDriver.cs | 80 ++++++++++++- .../EveUnityAdvertisedInputActionTests.cs | 81 +++++++++++++ .../EveUnitySceneSurfaceLowererTests.cs | 112 +++++++++++++++++- .../org.gamecult.eve.unity-scene/package.json | 4 +- 9 files changed, 339 insertions(+), 6 deletions(-) diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs index d3fdb61..0e38563 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs @@ -37,6 +37,7 @@ 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(); } [MessagePackObject] diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index 87896d6..4ece28c 100644 --- a/packages/org.gamecult.eve.surface/package.json +++ b/packages/org.gamecult.eve.surface/package.json @@ -1,12 +1,23 @@ { "name": "org.gamecult.eve.surface", "displayName": "Eve Surface Contract", - "version": "0.2.2", + "version": "0.2.3", "unity": "2021.3", "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", "dependencies": { "org.gamecult.cultlib": "1.0.8" }, + "files": [ + "Runtime", + "Runtime.meta", + "README.md", + "README.md.meta", + "Directory.Build.props", + "Directory.Build.props.meta", + "GameCult.Eve.Surface.csproj", + "GameCult.Eve.Surface.csproj.meta", + "package.json.meta" + ], "keywords": [ "Eve", "CultUI", diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 765188c..939c687 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -15,6 +15,13 @@ selection filters; they are not product knowledge required by the client. The package never imports provider product assemblies. +Advertised input bindings are performed from the provider's semantic action +catalog. Direct and chord gestures submit once per press edge; held values +submit their advertised press/release scalar. `view-direction.v1` samples the +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. + `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 33d5748..9976441 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 ViewDirectionValueModel = "view-direction.v1"; private readonly EveInputActionDocument _action; @@ -25,7 +26,9 @@ namespace GameCult.Eve.UnityScene public string Availability => _action.Availability ?? ""; public string InputValueModel => _action.InputValue?.Model ?? ""; public string InputValuePayloadKey => _action.InputValue?.PayloadKey ?? ""; + public IReadOnlyList InputValuePayloadKeys => _action.InputValue?.PayloadKeys ?? Array.Empty(); public bool IsButtonHold => string.Equals(InputValueModel, ButtonHoldValueModel, StringComparison.Ordinal); + public bool IsViewDirection => string.Equals(InputValueModel, ViewDirectionValueModel, StringComparison.Ordinal); public static EveUnityAdvertisedInputAction Resolve(EveInputCapabilityDocument? capability, string actionId) { @@ -59,5 +62,34 @@ namespace GameCult.Eve.UnityScene return payload; } + + public IReadOnlyDictionary BuildViewDirectionPayload( + string entityId, + float directionX, + float directionY, + float directionZ) + { + if (!IsViewDirection) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a view-direction value."); + if (InputValuePayloadKeys.Count != 3 || InputValuePayloadKeys.Any(string.IsNullOrWhiteSpace) || + InputValuePayloadKeys.Distinct(StringComparer.Ordinal).Count() != 3) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise three distinct view-direction payload keys."); + if (!IsFinite(directionX) || !IsFinite(directionY) || !IsFinite(directionZ)) + throw new ArgumentOutOfRangeException(nameof(directionX), "View direction must be finite."); + var length = Math.Sqrt( + (directionX * directionX) + + (directionY * directionY) + + (directionZ * directionZ)); + if (length <= 0.000001) + throw new ArgumentOutOfRangeException(nameof(directionX), "View direction must be non-zero."); + + var payload = BuildPayload(entityId).ToDictionary(value => value.Key, value => value.Value, StringComparer.Ordinal); + payload[InputValuePayloadKeys[0]] = ((float)(directionX / length)).ToString("R", CultureInfo.InvariantCulture); + payload[InputValuePayloadKeys[1]] = ((float)(directionY / length)).ToString("R", CultureInfo.InvariantCulture); + payload[InputValuePayloadKeys[2]] = ((float)(directionZ / length)).ToString("R", CultureInfo.InvariantCulture); + return payload; + } + + private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index 21c5694..8390830 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -227,6 +227,21 @@ namespace GameCult.Eve.UnityScene issuedAt); } + public EveSurfaceCommandRequest SubmitAdvertisedActionViewDirectionIntent( + string entityId, + string actionId, + float directionX, + float directionY, + float directionZ, + DateTimeOffset? issuedAt = null) + { + var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + return RequireRuntime().SubmitCommandIntent( + action.Operation, + action.BuildViewDirectionPayload(entityId, directionX, directionY, directionZ), + issuedAt); + } + public void Disconnect() { if (_cameraOwner is EveUnityPlayableWorldCameraRig rig) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs index 1dbca9c..1b2e8ca 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs @@ -35,6 +35,8 @@ namespace GameCult.Eve.UnityScene private string _lookOwnerKey = ""; private readonly Dictionary _heldBindings = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _performedBindings = + new Dictionary(StringComparer.Ordinal); public Func? DigitalControlReader { get; set; } @@ -126,6 +128,26 @@ namespace GameCult.Eve.UnityScene issuedAt); } + public EveSurfaceCommandRequest? SubmitViewDirectionAction( + string actionId, + DateTimeOffset? issuedAt = null) + { + var resolvedHost = ResolveHost(); + if (resolvedHost?.ActiveWorld == null || string.IsNullOrWhiteSpace(actionId)) + return null; + var view = cameraTransform ?? resolvedHost.ActiveCameraTransform; + if (view == null || view.forward.sqrMagnitude <= 0.000001f) + return null; + var direction = view.forward.normalized; + return resolvedHost.SubmitAdvertisedActionViewDirectionIntent( + resolvedHost.ActiveWorld.PlayerEntityId, + actionId, + direction.x, + direction.y, + direction.z, + issuedAt); + } + public EveSurfaceCommandRequest? SubmitLookInput( float horizontalDelta, DateTimeOffset? issuedAt = null) @@ -199,12 +221,18 @@ namespace GameCult.Eve.UnityScene _nextCommandAt = Time.unscaledTime + Math.Max(0.01f, commandIntervalSeconds); SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis)); SubmitPendingLookInput(); - SubmitChangedHeldActions(); + SubmitChangedAdvertisedActions(); if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton)) SubmitPrimaryAction(); } + public void SubmitChangedAdvertisedActions() + { + SubmitChangedHeldActions(); + SubmitChangedPerformedActions(); + } + private void SubmitChangedHeldActions() { var resolvedHost = ResolveHost(); @@ -245,6 +273,43 @@ namespace GameCult.Eve.UnityScene } } + private void SubmitChangedPerformedActions() + { + 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 == null || + !string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase) || + string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ButtonHoldValueModel, StringComparison.Ordinal) || + (!string.IsNullOrWhiteSpace(action.InputValue?.Model) && + !string.Equals(action.InputValue.Model, EveUnityAdvertisedInputAction.ViewDirectionValueModel, StringComparison.Ordinal)) || + !TryReadDigitalGesture(binding.Gesture, out var pressed)) + continue; + + var stateKey = string.IsNullOrWhiteSpace(binding.BindingId) ? binding.ActionId : binding.BindingId; + retained.Add(stateKey); + var wasPressed = _performedBindings.TryGetValue(stateKey, out var previous) && previous.Pressed; + _performedBindings[stateKey] = new PerformedBindingState(binding.ActionId, pressed); + if (!pressed || wasPressed) continue; + if (string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ViewDirectionValueModel, StringComparison.Ordinal)) + SubmitViewDirectionAction(binding.ActionId); + else + resolvedHost.SubmitAdvertisedActionIntent(resolvedHost.ActiveWorld.PlayerEntityId, binding.ActionId); + } + + foreach (var stale in _performedBindings.Keys.Where(key => !retained.Contains(key)).ToArray()) + _performedBindings.Remove(stale); + } + private bool TryReadDigitalGesture(EveInputGestureDocument? gesture, out bool pressed) { pressed = false; @@ -283,6 +348,7 @@ namespace GameCult.Eve.UnityScene foreach (var state in _heldBindings.Values) if (state.Pressed) TrySubmitRelease(state.ActionId); _heldBindings.Clear(); + _performedBindings.Clear(); } private void TrySubmitRelease(string actionId) @@ -327,6 +393,18 @@ namespace GameCult.Eve.UnityScene public string ActionId { get; } public bool Pressed { get; } } + + private readonly struct PerformedBindingState + { + public PerformedBindingState(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 index 8174a80..7f089b3 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs @@ -64,5 +64,86 @@ namespace GameCult.Eve.UnityScene.Tests Assert.Throws(() => action.BuildPayload("entity.4", 1f)); } + + [Test] + public void ViewDirectionUsesAdvertisedKeysAndNormalizesWithoutMutatingCapability() + { + var sourcePayload = new Dictionary(StringComparer.Ordinal) + { + ["mode"] = "reticle" + }; + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "provider.commands.TargetReticle", + Availability = "available", + Payload = sourcePayload, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "rayX", "rayY", "rayZ" } + } + } + } + }, "pilot.target-reticle"); + + var payload = action.BuildViewDirectionPayload("entity.4", 3f, 4f, 0f); + + Assert.That(action.IsViewDirection, Is.True); + Assert.That(payload["entityId"], Is.EqualTo("entity.4")); + Assert.That(payload["actionId"], Is.EqualTo("pilot.target-reticle")); + Assert.That(payload["mode"], Is.EqualTo("reticle")); + Assert.That(float.Parse(payload["rayX"], System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(0.6f).Within(0.000001f)); + Assert.That(float.Parse(payload["rayY"], System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(0.8f).Within(0.000001f)); + Assert.That(float.Parse(payload["rayZ"], System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(0f).Within(0.000001f)); + Assert.That(sourcePayload.ContainsKey("rayX"), Is.False); + Assert.Throws(() => action.BuildPayload("entity.4", 1f)); + } + + [Test] + public void ViewDirectionFailsClosedForMalformedKeysAndZeroVector() + { + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "provider.commands.TargetReticle", + Availability = "available", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "direction", "direction", "direction" } + } + } + } + }, "pilot.target-reticle"); + + Assert.Throws(() => action.BuildViewDirectionPayload("entity.4", 0f, 0f, 1f)); + action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "provider.commands.TargetReticle", + Availability = "available", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "x", "y", "z" } + } + } + } + }, "pilot.target-reticle"); + Assert.Throws(() => action.BuildViewDirectionPayload("entity.4", 0f, 0f, 0f)); + } } } 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 88e0841..d738875 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1114,6 +1114,108 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(capability), Is.Empty); } + [Test] + public void PlayableWorldInputDriverPerformsAdvertisedViewDirectionBindingOnPressEdge() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + var cameraObject = new GameObject("generic-eve-camera"); + hostObject.SetActive(false); + + try + { + cameraObject.transform.rotation = Quaternion.Euler(30f, 90f, 0f); + var capability = new EveInputCapabilityDocument + { + ProviderId = "generic-provider", + CapabilityId = "pilot.input", + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "generic.commands.TargetReticle", + Availability = "available", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "directionX", "directionY", "directionZ" } + } + } + }, + DefaultProfiles = new[] + { + new EveInputProfileDocument + { + ProfileId = "keyboard-mouse", + DeviceClass = "keyboard-mouse", + Bindings = new[] + { + new EveInputBindingDocument + { + BindingId = "target.reticle.r", + ActionId = "pilot.target-reticle", + Gesture = new EveInputGestureDocument + { + Kind = "direct", + Controls = new[] { "keyboard.r" } + } + } + } + } + } + }; + 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 pressed = false; + var driver = hostObject.AddComponent(); + driver.Host = host; + driver.CameraTransform = cameraObject.transform; + driver.DigitalControlReader = control => control == "keyboard.r" ? pressed : (bool?)null; + + driver.SubmitChangedAdvertisedActions(); + pressed = true; + driver.SubmitChangedAdvertisedActions(); + driver.SubmitChangedAdvertisedActions(); + + Assert.That(provider.Submitted.Count, Is.EqualTo(1)); + var request = provider.Submitted.Single(); + Assert.That(request.Payload.GetString("commandId"), Is.EqualTo("generic.commands.TargetReticle")); + Assert.That(request.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + Assert.That(request.Payload.GetString("actionId"), Is.EqualTo("pilot.target-reticle")); + var expected = cameraObject.transform.forward.normalized; + Assert.That(request.Payload.GetDouble("directionX", 0), Is.EqualTo(expected.x).Within(0.000001)); + Assert.That(request.Payload.GetDouble("directionY", 0), Is.EqualTo(expected.y).Within(0.000001)); + Assert.That(request.Payload.GetDouble("directionZ", 0), Is.EqualTo(expected.z).Within(0.000001)); + + pressed = false; + driver.SubmitChangedAdvertisedActions(); + pressed = true; + driver.SubmitChangedAdvertisedActions(); + Assert.That(provider.Submitted.Count, Is.EqualTo(2), + "a released and re-pressed generic binding must perform exactly once per press edge"); + } + finally + { + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + [Test] public void PlayableWorldCameraRigFollowsAdvertisedPlayerEntityWithoutProviderTypes() { @@ -2775,7 +2877,8 @@ namespace GameCult.Eve.UnityScene.Tests IEveUnityPlayableWorldAssetManifestDocumentSource, IEveUnitySceneCommandSink, IEveUnitySceneCommandReceiptSource, - IEveUnityProviderRefreshSource + IEveUnityProviderRefreshSource, + IEveUnityInputCapabilitySource { public List Submitted { get; } = new List(); @@ -2798,6 +2901,9 @@ namespace GameCult.Eve.UnityScene.Tests Array.Empty(), "aetheria"); + public EveInputCapabilityDocument CurrentInputCapability { get; private set; } = + new EveInputCapabilityDocument(); + EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => CurrentSurfaceDocument; @@ -2815,10 +2921,12 @@ namespace GameCult.Eve.UnityScene.Tests public void Set( EveUnitySceneProviderSurfaceDocument surfaceDocument, - EveUnityPlayableWorldAssetManifestDocument assetManifest) + EveUnityPlayableWorldAssetManifestDocument assetManifest, + EveInputCapabilityDocument? inputCapability = null) { CurrentSurfaceDocument = surfaceDocument; CurrentDocument = assetManifest; + CurrentInputCapability = inputCapability ?? new EveInputCapabilityDocument(); } public void Refresh() diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index c9bcdb6..48aa21e 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.63", + "version": "0.3.64", "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.2", + "org.gamecult.eve.surface": "0.2.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0"