Lower advertised view-direction inputs

This commit is contained in:
Meta Vens 2026-07-17 04:57:39 +01:00
commit 7647d37445
9 changed files with 339 additions and 6 deletions

View file

@ -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<string>();
}
[MessagePackObject]

View file

@ -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",

View file

@ -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

View file

@ -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<string> InputValuePayloadKeys => _action.InputValue?.PayloadKeys ?? Array.Empty<string>();
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<string, string> 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);
}
}

View file

@ -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)

View file

@ -35,6 +35,8 @@ namespace GameCult.Eve.UnityScene
private string _lookOwnerKey = "";
private readonly Dictionary<string, HeldBindingState> _heldBindings =
new Dictionary<string, HeldBindingState>(StringComparer.Ordinal);
private readonly Dictionary<string, PerformedBindingState> _performedBindings =
new Dictionary<string, PerformedBindingState>(StringComparer.Ordinal);
public Func<string, bool?>? 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<EveInputProfileDocument>())
.FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal));
var retained = new HashSet<string>(StringComparer.Ordinal);
foreach (var binding in profile?.Bindings ?? Array.Empty<EveInputBindingDocument>())
{
var action = (capability.Actions ?? Array.Empty<EveInputActionDocument>())
.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

View file

@ -64,5 +64,86 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.Throws<InvalidOperationException>(() => action.BuildPayload("entity.4", 1f));
}
[Test]
public void ViewDirectionUsesAdvertisedKeysAndNormalizesWithoutMutatingCapability()
{
var sourcePayload = new Dictionary<string, string>(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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ArgumentOutOfRangeException>(() => action.BuildViewDirectionPayload("entity.4", 0f, 0f, 0f));
}
}
}

View file

@ -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<FakePlayableWorldProviderComponent>();
provider.Set(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://generic/eve/surfaces/game",
1),
new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://generic/assets/manifest",
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"generic-provider"),
capability);
var host = hostObject.AddComponent<EveUnityPlayableWorldClientHost>();
host.Configure(rootObject.transform, provider, provider, provider, provider);
host.Connect();
var pressed = false;
var driver = hostObject.AddComponent<EveUnityPlayableWorldInputDriver>();
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<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
@ -2798,6 +2901,9 @@ namespace GameCult.Eve.UnityScene.Tests
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"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()

View file

@ -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"