Lower authoritative pilot aim in generic worlds

This commit is contained in:
Meta Vens 2026-07-15 05:55:53 +01:00
commit 3075f71ece
15 changed files with 405 additions and 3 deletions

View file

@ -0,0 +1,49 @@
using System;
using System.Globalization;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityAimPresentation
{
public EveUnityAimPresentation(EveUnitySceneNode node)
{
if (node == null) throw new ArgumentNullException(nameof(node));
ControlledEntityId = Get(node, "controlledEntityId");
ConvergenceTargetEntityId = Get(node, "convergenceTargetEntityId");
ViewDotRole = Get(node, "viewDotRole");
MinimumConvergenceDistance = Math.Max(0.01f, Number(node, "minimumConvergenceDistance", 50f));
ViewDotRadius = Math.Max(0.01f, Number(node, "viewDotRadius", 0.8f));
}
public string ControlledEntityId { get; }
public string ConvergenceTargetEntityId { get; }
public string ViewDotRole { get; }
public float MinimumConvergenceDistance { get; }
public float ViewDotRadius { get; }
public static EveUnityAimPresentation? Find(EveUnitySceneProjection? projection) =>
projection == null ? null : Find(projection.Root);
private static EveUnityAimPresentation? Find(EveUnitySceneNode node)
{
if (string.Equals(node.ComponentKind, "aim.presentation", StringComparison.Ordinal))
return new EveUnityAimPresentation(node);
foreach (var child in node.Children)
{
var found = Find(child);
if (found != null) return found;
}
return null;
}
private static string Get(EveUnitySceneNode node, string key) =>
node.Props.TryGetValue(key, out var value) ? value ?? "" : "";
private static float Number(EveUnitySceneNode node, string key, float fallback) =>
float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
? value
: fallback;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f859926d39664cefa5aa96ed3c2d8903

View file

@ -0,0 +1,136 @@
using System;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityAimPresentationRenderer : MonoBehaviour
{
[SerializeField] private Color viewDotColor = new Color(0.35f, 0.95f, 1f, 0.95f);
[SerializeField] private float width = 0.1f;
private EveUnityPlayableWorldClientHost? _host;
private LineRenderer? _ring;
private LineRenderer? _horizontal;
private LineRenderer? _vertical;
private Material? _material;
public EveUnityAimPresentation? Current { get; private set; }
public bool ViewDotVisible => _ring != null && _ring.enabled;
public Vector3 ViewDotPosition { get; private set; }
public void Bind(EveUnityPlayableWorldClientHost host)
{
_host = host != null ? host : throw new ArgumentNullException(nameof(host));
}
public void RefreshNow()
{
Current = EveUnityAimPresentation.Find(_host?.ActiveProjection);
if (Current == null || string.IsNullOrWhiteSpace(Current.ControlledEntityId))
{
SetVisible(false);
return;
}
var controlled = FindMarker(Current.ControlledEntityId);
if (controlled == null)
{
SetVisible(false);
return;
}
var distance = Current.MinimumConvergenceDistance;
var target = FindMarker(Current.ConvergenceTargetEntityId);
if (target != null)
distance = Mathf.Max(distance, Vector3.Distance(controlled.transform.position, target.transform.position));
var forward = controlled.transform.forward;
forward.y = 0f;
if (forward.sqrMagnitude <= 0.0001f) forward = Vector3.forward;
forward.Normalize();
ViewDotPosition = controlled.transform.position + forward * distance;
EnsureVisuals();
var camera = Camera.main;
var right = camera == null ? Vector3.right : camera.transform.right;
var up = camera == null ? Vector3.up : camera.transform.up;
var center = ViewDotPosition + (camera == null ? Vector3.zero : -camera.transform.forward * 0.1f);
Ring(_ring!, center, right, up, Current.ViewDotRadius);
Segment(_horizontal!, center, right, Current.ViewDotRadius * 1.6f);
Segment(_vertical!, center, up, Current.ViewDotRadius * 1.6f);
SetVisible(true);
}
private void LateUpdate() => RefreshNow();
private EveUnityPlayableWorldEntityMarker? FindMarker(string entityId)
{
if (_host == null || string.IsNullOrWhiteSpace(entityId)) return null;
foreach (var marker in _host.SceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(true))
if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker;
return null;
}
private void EnsureVisuals()
{
if (_ring != null) return;
var root = new GameObject("Eve aim presentation");
root.transform.SetParent(transform, false);
_ring = Line(root.transform, "View direction ring");
_horizontal = Line(root.transform, "View direction horizontal");
_vertical = Line(root.transform, "View direction vertical");
}
private LineRenderer Line(Transform parent, string name)
{
var child = new GameObject(name);
child.transform.SetParent(parent, false);
var line = child.AddComponent<LineRenderer>();
line.useWorldSpace = true;
line.material = SharedMaterial();
line.startColor = line.endColor = viewDotColor;
line.startWidth = line.endWidth = Mathf.Max(0.01f, width);
line.numCapVertices = 2;
return line;
}
private Material SharedMaterial()
{
if (_material != null) return _material;
var shader = Shader.Find("Sprites/Default") ?? Shader.Find("Universal Render Pipeline/Unlit");
if (shader == null) throw new InvalidOperationException("EveUnity requires an unlit shader for aim presentation.");
return _material = new Material(shader) { name = "EveUnity aim presentation" };
}
private static void Ring(LineRenderer line, Vector3 center, Vector3 right, Vector3 up, float radius)
{
const int segments = 33;
line.positionCount = segments;
for (var index = 0; index < segments; index++)
{
var angle = index / (float)(segments - 1) * Mathf.PI * 2f;
line.SetPosition(index, center + right * (Mathf.Cos(angle) * radius) + up * (Mathf.Sin(angle) * radius));
}
}
private static void Segment(LineRenderer line, Vector3 center, Vector3 axis, float radius)
{
line.positionCount = 2;
line.SetPosition(0, center - axis * radius);
line.SetPosition(1, center + axis * radius);
}
private void SetVisible(bool visible)
{
if (_ring != null) _ring.enabled = visible;
if (_horizontal != null) _horizontal.enabled = visible;
if (_vertical != null) _vertical.enabled = visible;
}
private void OnDestroy()
{
if (_material != null) Destroy(_material);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 97c7b809756a4350a6e61c67bba69b82

View file

@ -95,6 +95,9 @@ namespace GameCult.Eve.UnityScene
var combatPresentation = GetComponent<EveUnityCombatPresentationRenderer>();
if (combatPresentation == null) combatPresentation = gameObject.AddComponent<EveUnityCombatPresentationRenderer>();
combatPresentation.Bind(this);
var aimPresentation = GetComponent<EveUnityAimPresentationRenderer>();
if (aimPresentation == null) aimPresentation = gameObject.AddComponent<EveUnityAimPresentationRenderer>();
aimPresentation.Bind(this);
var thermal = GetComponent<EveUnityThermalPresenter>();
var thermalHud = GetComponent<EveUnityThermalHudSink>();
if (thermalHud == null) thermalHud = gameObject.AddComponent<EveUnityThermalHudSink>();
@ -135,6 +138,16 @@ namespace GameCult.Eve.UnityScene
return RequireRuntime().SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt);
}
public EveSurfaceCommandRequest SubmitLookDirectionIntent(
string entityId,
float directionX,
float directionY,
float directionZ,
DateTimeOffset? issuedAt = null)
{
return RequireRuntime().SubmitLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt);
}
public EveSurfaceCommandRequest SubmitFocusIntent(
string entityId,
DateTimeOffset? issuedAt = null)

View file

@ -20,12 +20,16 @@ namespace GameCult.Eve.UnityScene
[SerializeField] private string horizontalAxis = "Horizontal";
[SerializeField] private string verticalAxis = "Vertical";
[SerializeField] private string actionButton = "Fire1";
[SerializeField] private string lookHorizontalAxis = "Mouse X";
[SerializeField] private float defaultLookSensitivityRadians = 0.001f;
[SerializeField] private float deadZone = 0.01f;
[SerializeField] private float commandIntervalSeconds = 0.05f;
[SerializeField] private bool submitZeroOnRelease = true;
private float _nextCommandAt;
private bool _wasMoving;
private float _lookYawRadians;
private bool _hasLookYaw;
public EveUnityPlayableWorldClientHost? Host
{
@ -94,6 +98,34 @@ namespace GameCult.Eve.UnityScene
resolvedActionId);
}
public EveSurfaceCommandRequest? SubmitLookInput(
float horizontalDelta,
DateTimeOffset? issuedAt = null)
{
var resolvedHost = ResolveHost();
var world = resolvedHost?.ActiveWorld;
if (world == null || string.IsNullOrWhiteSpace(world.LookCommand) ||
Mathf.Abs(horizontalDelta) <= 0.0001f)
return null;
if (!_hasLookYaw)
{
_lookYawRadians = ResolveControlledYawRadians(resolvedHost!, world.PlayerEntityId);
_hasLookYaw = true;
}
var sensitivity = Mathf.Abs(world.LookSensitivityRadians) > 0.000001f
? world.LookSensitivityRadians
: defaultLookSensitivityRadians;
_lookYawRadians += horizontalDelta * sensitivity;
var direction = EveUnityPlayableWorldLookDirection.FromPlanarYaw(_lookYawRadians);
return resolvedHost!.SubmitLookDirectionIntent(
world.PlayerEntityId,
direction.DirectionX,
0f,
direction.DirectionZ,
issuedAt);
}
public static string ResolvePrimaryActionId(EveInputCapabilityDocument? capability)
{
if (capability == null)
@ -121,6 +153,8 @@ namespace GameCult.Eve.UnityScene
_nextCommandAt = Time.unscaledTime + Math.Max(0.01f, commandIntervalSeconds);
SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis));
if (!string.IsNullOrWhiteSpace(lookHorizontalAxis))
SubmitLookInput(Input.GetAxisRaw(lookHorizontalAxis));
if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton))
SubmitPrimaryAction();
@ -134,6 +168,29 @@ namespace GameCult.Eve.UnityScene
host = GetComponent<EveUnityPlayableWorldClientHost>();
return host;
}
private static float ResolveControlledYawRadians(EveUnityPlayableWorldClientHost host, string entityId)
{
foreach (var marker in host.SceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(true))
if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal))
return marker.transform.eulerAngles.y * Mathf.Deg2Rad;
return 0f;
}
}
public readonly struct EveUnityPlayableWorldLookDirection
{
public EveUnityPlayableWorldLookDirection(float directionX, float directionZ)
{
DirectionX = directionX;
DirectionZ = directionZ;
}
public float DirectionX { get; }
public float DirectionZ { get; }
public static EveUnityPlayableWorldLookDirection FromPlanarYaw(float yawRadians) =>
new EveUnityPlayableWorldLookDirection(Mathf.Sin(yawRadians), Mathf.Cos(yawRadians));
}
public readonly struct EveUnityPlayableWorldMoveVector

View file

@ -88,6 +88,16 @@ namespace GameCult.Eve.UnityScene
return _connection.SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt);
}
public EveSurfaceCommandRequest SubmitLookDirectionIntent(
string entityId,
float directionX,
float directionY,
float directionZ,
DateTimeOffset? issuedAt = null)
{
return _connection.SubmitLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt);
}
public EveSurfaceCommandRequest SubmitFocusIntent(
string entityId,
DateTimeOffset? issuedAt = null)

View file

@ -143,6 +143,16 @@ namespace GameCult.Eve.UnityScene
return _client.SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt);
}
public EveSurfaceCommandRequest SubmitLookDirectionIntent(
string entityId,
float directionX,
float directionY,
float directionZ,
DateTimeOffset? issuedAt = null)
{
return _client.SubmitLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt);
}
public EveSurfaceCommandRequest SubmitFocusIntent(
string entityId,
DateTimeOffset? issuedAt = null)

View file

@ -83,6 +83,26 @@ namespace GameCult.Eve.UnityScene
issuedAt);
}
public EveSurfaceCommandRequest CreateLookDirectionIntent(
string entityId,
float directionX,
float directionY,
float directionZ,
DateTimeOffset? issuedAt = null)
{
var playableWorld = RequirePlayableWorld();
return CreatePlayableWorldIntent(
playableWorld.LookCommand,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["entityId"] = entityId ?? "",
["directionX"] = FormatFloat(directionX),
["directionY"] = FormatFloat(directionY),
["directionZ"] = FormatFloat(directionZ)
},
issuedAt);
}
public EveSurfaceCommandRequest CreateFocusIntent(
string entityId,
DateTimeOffset? issuedAt = null)

View file

@ -221,6 +221,16 @@ namespace GameCult.Eve.UnityScene
return Submit(_session.CreateMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt));
}
public EveSurfaceCommandRequest SubmitLookDirectionIntent(
string entityId,
float directionX,
float directionY,
float directionZ,
DateTimeOffset? issuedAt = null)
{
return Submit(_session.CreateLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt));
}
public EveSurfaceCommandRequest SubmitFocusIntent(
string entityId,
DateTimeOffset? issuedAt = null)

View file

@ -136,7 +136,9 @@ namespace GameCult.Eve.UnityScene
ParseVector3(worldRoot.GetProp("ambientLightColor")),
ParseFloat(worldRoot.GetProp("ambientLightIntensity"), 1f),
ParseFloat(worldRoot.GetProp("cameraNearClipPlane"), 0f),
ParseFloat(worldRoot.GetProp("cameraFarClipPlane"), 0f));
ParseFloat(worldRoot.GetProp("cameraFarClipPlane"), 0f),
worldRoot.GetProp("lookCommand"),
ParseFloat(worldRoot.GetProp("lookSensitivityRadians"), 0f));
}
private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component)
@ -357,7 +359,9 @@ namespace GameCult.Eve.UnityScene
(float r, float g, float b) ambientLightColor = default,
float ambientLightIntensity = 1f,
float cameraNearClipPlane = 0f,
float cameraFarClipPlane = 0f)
float cameraFarClipPlane = 0f,
string lookCommand = "",
float lookSensitivityRadians = 0f)
{
WorldRootId = worldRootId ?? "";
StatePointerId = statePointerId ?? "";
@ -388,6 +392,8 @@ namespace GameCult.Eve.UnityScene
AmbientLightIntensity = ambientLightIntensity;
CameraNearClipPlane = cameraNearClipPlane;
CameraFarClipPlane = cameraFarClipPlane;
LookCommand = lookCommand ?? "";
LookSensitivityRadians = lookSensitivityRadians;
}
public string WorldRootId { get; }
@ -438,6 +444,10 @@ namespace GameCult.Eve.UnityScene
public string MovementCommand { get; }
public string LookCommand { get; }
public float LookSensitivityRadians { get; }
public string FocusCommand { get; }
public string TargetCommand { get; }

View file

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityAimPresentationTests
{
[Test]
public void ProjectionPreservesProviderAimConvergenceSemantic()
{
var projection = new EveUnitySceneProjection(
"provider", "surface", "world", "commands", "receipt", "provider", null,
Node("root", "surface", Node("aim", "aim.presentation", new Dictionary<string, string>
{
["controlledEntityId"] = "run.zone.0.entity.4",
["convergenceTargetEntityId"] = "run.zone.0.entity.9",
["viewDotRole"] = "aim.marker.view-direction",
["minimumConvergenceDistance"] = "50",
["viewDotRadius"] = "0.8"
})));
var aim = EveUnityAimPresentation.Find(projection);
Assert.That(aim, Is.Not.Null);
Assert.That(aim!.ControlledEntityId, Is.EqualTo("run.zone.0.entity.4"));
Assert.That(aim.ConvergenceTargetEntityId, Is.EqualTo("run.zone.0.entity.9"));
Assert.That(aim.MinimumConvergenceDistance, Is.EqualTo(50f));
Assert.That(aim.ViewDotRadius, Is.EqualTo(0.8f));
}
[Test]
public void PlanarYawProducesPortableUnitLookDirection()
{
var forward = EveUnityPlayableWorldLookDirection.FromPlanarYaw(0f);
var right = EveUnityPlayableWorldLookDirection.FromPlanarYaw(Mathf.PI * 0.5f);
Assert.That(forward.DirectionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(forward.DirectionZ, Is.EqualTo(1f).Within(0.0001f));
Assert.That(right.DirectionX, Is.EqualTo(1f).Within(0.0001f));
Assert.That(right.DirectionZ, Is.EqualTo(0f).Within(0.0001f));
}
private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) =>
Node(id, kind, Empty(), children);
private static EveUnitySceneNode Node(string id, string kind, IReadOnlyDictionary<string, string> props, params EveUnitySceneNode[] children) =>
new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0,
Array.Empty<EveUnitySceneEmbeddedDocumentSlot>(), null, children);
private static IReadOnlyDictionary<string, string> Empty() =>
new Dictionary<string, string>(StringComparer.Ordinal);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2bcc030e48554a49accc4efc105c36b4

View file

@ -512,6 +512,18 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(moveVectorIntent.Payload.GetString("directionX"), Is.EqualTo("0.25"));
Assert.That(moveVectorIntent.Payload.GetString("directionY"), Is.EqualTo("0.75"));
Assert.That(moveVectorIntent.Payload.GetString("scalarValue"), Is.EqualTo("0.8"));
var lookIntent = session.CreateLookDirectionIntent(
"player-vanguard",
0.6f,
0f,
0.8f,
DateTimeOffset.Parse("2026-07-09T00:00:02Z"));
Assert.That(lookIntent.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.look_intent"));
Assert.That(lookIntent.Payload.GetString("entityId"), Is.EqualTo("player-vanguard"));
Assert.That(lookIntent.Payload.GetString("directionX"), Is.EqualTo("0.6"));
Assert.That(lookIntent.Payload.GetString("directionY"), Is.EqualTo("0"));
Assert.That(lookIntent.Payload.GetString("directionZ"), Is.EqualTo("0.8"));
}
[Test]
@ -978,6 +990,16 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(request.Payload.GetDouble("directionY", 0), Is.EqualTo(0f).Within(0.0001f));
Assert.That(request.Payload.GetDouble("scalarValue", 0), Is.EqualTo(1f).Within(0.0001f));
var controlledYaw = rootObject.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(true)
.Single(marker => marker.EntityId == "player-vanguard")
.transform.eulerAngles.y * Mathf.Deg2Rad;
var look = driver.SubmitLookInput(1000f);
Assert.That(look, Is.Not.Null);
Assert.That(provider.Submitted.Count, Is.EqualTo(2));
Assert.That(look!.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.look_intent"));
Assert.That(look.Payload.GetDouble("directionX", 0), Is.EqualTo(Mathf.Sin(controlledYaw - 1f)).Within(0.0001f));
Assert.That(look.Payload.GetDouble("directionZ", 0), Is.EqualTo(Mathf.Cos(controlledYaw - 1f)).Within(0.0001f));
var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput(1f, 1f, null);
Assert.That(movement.HasInput, Is.True);
Assert.That(movement.ScalarValue, Is.EqualTo(1f).Within(0.0001f));
@ -1752,6 +1774,8 @@ namespace GameCult.Eve.UnityScene.Tests
["excludedRenderChannels"] = "map",
["playerEntityId"] = "player-vanguard",
["movementCommand"] = "aetheria.daemon.move_intent",
["lookCommand"] = "aetheria.daemon.look_intent",
["lookSensitivityRadians"] = "-0.001",
["focusCommand"] = "aetheria.daemon.focus",
["targetCommand"] = "aetheria.daemon.target",
["actionCommand"] = "aetheria.daemon.use_equipment"

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.23",
"version": "0.3.24",
"unity": "2021.3",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {