Lower provider combat feedback in EveUnity

This commit is contained in:
Meta Vens 2026-07-12 01:15:51 +01:00
commit 739cd9862f
15 changed files with 493 additions and 0 deletions

View file

@ -15,6 +15,13 @@ selection filters; they are not product knowledge required by the client.
The package never imports provider product assemblies.
Retained provider feedback is lowered without becoming Unity authority.
`EveUnityFeedbackPresenter` emits each new `feedback.event` identity once, and
`EveUnityShotReceiptPresenter` does the same for `shot.receipt` trajectories.
The generic `EveUnityShotTrajectoryRenderer` renders provider-authored origin,
endpoint, duration, and hit outcome, then expires the visual locally. It never
uses Unity collision to infer damage or report a hit back to the provider.
`fields.surface` projection consumes the plugin-owned
`org.gamecult.eve.plugin-fields` contracts. `EveFieldsSplatRasterizer` owns the
Unity structured-buffer and RenderTexture lowering without importing provider

View file

@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Globalization;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityFeedbackEvent
{
public EveUnityFeedbackEvent(EveUnitySceneNode node)
{
EventId = Get(node, "eventId"); Kind = Get(node, "eventKind"); SubjectKey = Get(node, "subjectKey");
ItemKey = Get(node, "itemKey"); Position = Get(node, "position");
FrameId = Long(node, "frameId", -1); ZoneIndex = Int(node, "zoneIndex", -1);
SourceEntityIndex = Int(node, "sourceEntityIndex", -1); TargetEntityIndex = Int(node, "targetEntityIndex", -1);
PickupIndex = Int(node, "pickupIndex", -1); ScalarValue = Double(node, "scalarValue", 0);
}
public string EventId { get; }
public string Kind { get; }
public long FrameId { get; }
public int ZoneIndex { get; }
public int SourceEntityIndex { get; }
public int TargetEntityIndex { get; }
public int PickupIndex { get; }
public string SubjectKey { get; }
public string ItemKey { get; }
public string Position { get; }
public double ScalarValue { get; }
private static string Get(EveUnitySceneNode node, string key) => node.Props.TryGetValue(key, out var value) ? value ?? "" : "";
private static int Int(EveUnitySceneNode node, string key, int fallback) => int.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static long Long(EveUnitySceneNode node, string key, long fallback) => long.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static double Double(EveUnitySceneNode node, string key, double fallback) => double.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback;
}
public sealed class EveUnityFeedbackPresenter
{
private readonly HashSet<string> _seen = new HashSet<string>(StringComparer.Ordinal);
private bool _primed;
public event Action<EveUnityFeedbackEvent>? FeedbackAvailable;
public int Apply(EveUnitySceneProjection projection)
{
if (projection == null) throw new ArgumentNullException(nameof(projection));
var pending = new List<EveUnityFeedbackEvent>();
Collect(projection.Root, pending);
if (!_primed)
{
foreach (var value in pending) if (!string.IsNullOrWhiteSpace(value.EventId)) _seen.Add(value.EventId);
_primed = true;
return 0;
}
var emitted = 0;
foreach (var value in pending)
{
if (string.IsNullOrWhiteSpace(value.EventId) || !_seen.Add(value.EventId)) continue;
FeedbackAvailable?.Invoke(value); emitted++;
}
return emitted;
}
public void Reset() { _seen.Clear(); _primed = false; }
private static void Collect(EveUnitySceneNode node, List<EveUnityFeedbackEvent> values)
{
if (string.Equals(node.ComponentKind, "feedback.event", StringComparison.Ordinal)) values.Add(new EveUnityFeedbackEvent(node));
foreach (var child in node.Children) Collect(child, values);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28dc73d4047dbbf4f926612650a72592

View file

@ -18,6 +18,7 @@ namespace GameCult.Eve.UnityScene
[SerializeField] private bool connectOnEnable;
[SerializeField] private bool refreshInUpdate = true;
[SerializeField] private float refreshIntervalSeconds = 0.1f;
[SerializeField] private bool renderShotTrajectories = true;
private float _nextRefreshAt;
@ -33,6 +34,9 @@ namespace GameCult.Eve.UnityScene
public long ActiveVersion => Runtime?.ActiveVersion ?? 0;
public event Action<EveUnityFeedbackEvent>? FeedbackAvailable;
public event Action<EveUnityShotReceipt>? ShotAvailable;
public Transform SceneRoot => sceneRoot == null ? transform : sceneRoot;
public bool ConnectOnEnable
@ -73,6 +77,14 @@ namespace GameCult.Eve.UnityScene
Optional<IEveUnityPlayableWorldAssetManifestDocumentSource>(assetManifestDocuments),
Optional<IEveUnitySceneCommandReceiptSource>(receiptSource),
Optional<IEveUnityGameObjectAssetProvider>(fallbackAssetProvider));
Runtime.FeedbackAvailable += OnFeedbackAvailable;
Runtime.ShotAvailable += OnShotAvailable;
if (renderShotTrajectories)
{
var trajectories = GetComponent<EveUnityShotTrajectoryRenderer>();
if (trajectories == null) trajectories = gameObject.AddComponent<EveUnityShotTrajectoryRenderer>();
trajectories.Bind(this);
}
return Runtime.Connect();
}
@ -131,10 +143,18 @@ namespace GameCult.Eve.UnityScene
public void Disconnect()
{
if (Runtime != null)
{
Runtime.FeedbackAvailable -= OnFeedbackAvailable;
Runtime.ShotAvailable -= OnShotAvailable;
}
Runtime?.Dispose();
Runtime = null;
}
private void OnFeedbackAvailable(EveUnityFeedbackEvent value) => FeedbackAvailable?.Invoke(value);
private void OnShotAvailable(EveUnityShotReceipt value) => ShotAvailable?.Invoke(value);
private void OnEnable()
{
if (connectOnEnable)

View file

@ -12,6 +12,8 @@ namespace GameCult.Eve.UnityScene
private readonly EveUnityPlayableWorldPresenter _presenter;
private readonly IEveUnitySceneCommandReceiptSource? _receiptSource;
private readonly bool _refreshOnTerminalReceipt;
private readonly EveUnityFeedbackPresenter _feedback = new EveUnityFeedbackPresenter();
private readonly EveUnityShotReceiptPresenter _shots = new EveUnityShotReceiptPresenter();
private readonly List<EveUnitySceneCommandReceipt> _pendingReceipts =
new List<EveUnitySceneCommandReceipt>();
private bool _connected;
@ -42,6 +44,17 @@ namespace GameCult.Eve.UnityScene
public string SourcePointer => _connection.SourcePointer;
public event Action<EveUnitySceneCommandReceipt>? ReceiptAvailable;
public event Action<EveUnityFeedbackEvent>? FeedbackAvailable
{
add { _feedback.FeedbackAvailable += value; }
remove { _feedback.FeedbackAvailable -= value; }
}
public event Action<EveUnityShotReceipt>? ShotAvailable
{
add { _shots.ShotAvailable += value; }
remove { _shots.ShotAvailable -= value; }
}
public EveUnityPlayableWorldPresentation Connect()
{
@ -116,6 +129,8 @@ namespace GameCult.Eve.UnityScene
}
_connection.Disconnect();
_feedback.Reset();
_shots.Reset();
_pendingReceipts.Clear();
}
@ -146,6 +161,8 @@ namespace GameCult.Eve.UnityScene
private void OnProjectionUpdated(EveUnitySceneProjection projection)
{
LastPresentation = _presenter.Apply(projection);
_feedback.Apply(projection);
_shots.Apply(projection);
PublishReceiptsWhoseStateIsVisible();
}

View file

@ -59,6 +59,18 @@ namespace GameCult.Eve.UnityScene
remove { _client.ReceiptAvailable -= value; }
}
public event Action<EveUnityFeedbackEvent>? FeedbackAvailable
{
add { _client.FeedbackAvailable += value; }
remove { _client.FeedbackAvailable -= value; }
}
public event Action<EveUnityShotReceipt>? ShotAvailable
{
add { _client.ShotAvailable += value; }
remove { _client.ShotAvailable -= value; }
}
public static EveUnityPlayableWorldRuntime CreateForGameObjectScene(
Transform root,
IEveUnitySceneProviderSurfaceDocumentSource surfaceDocuments,

View file

@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Globalization;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public readonly struct EveUnityShotVector3
{
public EveUnityShotVector3(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
public double X { get; }
public double Y { get; }
public double Z { get; }
}
public sealed class EveUnityShotReceipt
{
public EveUnityShotReceipt(EveUnitySceneNode node)
{
ShotId = Get(node, "shotId");
FrameId = Long(node, "frameId", -1);
ZoneIndex = Int(node, "zoneIndex", -1);
SourceEntityIndex = Int(node, "sourceEntityIndex", -1);
TargetEntityIndex = Int(node, "targetEntityIndex", -1);
Hit = Bool(node, "hit");
Outcome = Get(node, "outcome");
Origin = Vector(node, "origin");
Endpoint = Vector(node, "endpoint");
DurationSeconds = Math.Max(0, Double(node, "presentationDuration", 0));
PresentationKind = Get(node, "presentationKind");
}
public string ShotId { get; }
public long FrameId { get; }
public int ZoneIndex { get; }
public int SourceEntityIndex { get; }
public int TargetEntityIndex { get; }
public bool Hit { get; }
public string Outcome { get; }
public EveUnityShotVector3 Origin { get; }
public EveUnityShotVector3 Endpoint { get; }
public double DurationSeconds { get; }
public string PresentationKind { get; }
private static string Get(EveUnitySceneNode node, string key) =>
node.Props.TryGetValue(key, out var value) ? value ?? "" : "";
private static int Int(EveUnitySceneNode node, string key, int fallback) =>
int.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static long Long(EveUnitySceneNode node, string key, long fallback) =>
long.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static double Double(EveUnitySceneNode node, string key, double fallback) =>
double.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static bool Bool(EveUnitySceneNode node, string key) =>
bool.TryParse(Get(node, key), out var value) && value;
private static EveUnityShotVector3 Vector(EveUnitySceneNode node, string key)
{
var parts = Get(node, key).Split(',');
if (parts.Length < 2) return new EveUnityShotVector3(0, 0, 0);
var x = double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedX) ? parsedX : 0;
var z = double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedZ) ? parsedZ : 0;
return new EveUnityShotVector3(x, 0, z);
}
}
public sealed class EveUnityShotReceiptPresenter
{
private readonly HashSet<string> _seen = new HashSet<string>(StringComparer.Ordinal);
private bool _primed;
public event Action<EveUnityShotReceipt>? ShotAvailable;
public int Apply(EveUnitySceneProjection projection)
{
if (projection == null) throw new ArgumentNullException(nameof(projection));
var receipts = new List<EveUnityShotReceipt>();
Collect(projection.Root, receipts);
if (!_primed)
{
foreach (var receipt in receipts)
if (!string.IsNullOrWhiteSpace(receipt.ShotId)) _seen.Add(receipt.ShotId);
_primed = true;
return 0;
}
var emitted = 0;
foreach (var receipt in receipts)
{
if (string.IsNullOrWhiteSpace(receipt.ShotId) || !_seen.Add(receipt.ShotId)) continue;
ShotAvailable?.Invoke(receipt);
emitted++;
}
return emitted;
}
public void Reset()
{
_seen.Clear();
_primed = false;
}
private static void Collect(EveUnitySceneNode node, ICollection<EveUnityShotReceipt> receipts)
{
if (string.Equals(node.ComponentKind, "shot.receipt", StringComparison.Ordinal))
receipts.Add(new EveUnityShotReceipt(node));
foreach (var child in node.Children) Collect(child, receipts);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7af61307fcb149fc96d9f5d67f5aa1f3

View file

@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityShotTrajectoryRenderer : MonoBehaviour
{
[SerializeField] private float width = 0.2f;
[SerializeField] private float minimumDurationSeconds = 0.03f;
[SerializeField] private float lingerSeconds = 0.08f;
[SerializeField] private Color hitColor = new Color(1f, 0.35f, 0.12f, 1f);
[SerializeField] private Color missColor = new Color(0.35f, 0.8f, 1f, 0.8f);
private readonly List<ActiveTrajectory> _active = new List<ActiveTrajectory>();
private EveUnityPlayableWorldClientHost? _host;
private Material? _material;
public int ActiveTrajectoryCount => _active.Count;
public void Bind(EveUnityPlayableWorldClientHost host)
{
if (host == null) throw new ArgumentNullException(nameof(host));
if (ReferenceEquals(_host, host)) return;
Unbind();
_host = host;
_host.ShotAvailable += Present;
}
public void Unbind()
{
if (_host != null) _host.ShotAvailable -= Present;
_host = null;
}
public void Present(EveUnityShotReceipt receipt)
{
if (receipt == null) throw new ArgumentNullException(nameof(receipt));
var shot = new GameObject("Eve shot " + receipt.ShotId);
shot.transform.SetParent(transform, false);
var line = shot.AddComponent<LineRenderer>();
line.useWorldSpace = true;
line.positionCount = 2;
line.startWidth = Math.Max(0.01f, width);
line.endWidth = Math.Max(0.01f, width * 0.5f);
line.material = SharedMaterial();
line.startColor = line.endColor = receipt.Hit ? hitColor : missColor;
var origin = Vector(receipt.Origin);
line.SetPosition(0, origin);
line.SetPosition(1, origin);
_active.Add(new ActiveTrajectory(
shot,
line,
origin,
Vector(receipt.Endpoint),
Time.unscaledTime,
Math.Max(minimumDurationSeconds, (float)receipt.DurationSeconds)));
}
private void Update()
{
var now = Time.unscaledTime;
for (var index = _active.Count - 1; index >= 0; index--)
{
var trajectory = _active[index];
var progress = Mathf.Clamp01((now - trajectory.StartedAt) / trajectory.Duration);
trajectory.Line.SetPosition(1, Vector3.Lerp(trajectory.Origin, trajectory.Endpoint, progress));
if (progress < 1 || now < trajectory.StartedAt + trajectory.Duration + lingerSeconds) continue;
Destroy(trajectory.Root);
_active.RemoveAt(index);
}
}
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 shot trajectories.");
_material = new Material(shader) { name = "EveUnity shot trajectory" };
return _material;
}
private void OnDestroy()
{
Unbind();
foreach (var trajectory in _active)
if (trajectory.Root != null) Destroy(trajectory.Root);
_active.Clear();
if (_material != null) Destroy(_material);
}
private static Vector3 Vector(EveUnityShotVector3 value) =>
new Vector3((float)value.X, (float)value.Y, (float)value.Z);
private sealed class ActiveTrajectory
{
public ActiveTrajectory(GameObject root, LineRenderer line, Vector3 origin, Vector3 endpoint, float startedAt, float duration)
{
Root = root;
Line = line;
Origin = origin;
Endpoint = endpoint;
StartedAt = startedAt;
Duration = duration;
}
public GameObject Root { get; }
public LineRenderer Line { get; }
public Vector3 Origin { get; }
public Vector3 Endpoint { get; }
public float StartedAt { get; }
public float Duration { get; }
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96ad4b7918444a82bc1ba2fd37ac2e39

View file

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
#nullable enable
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityFeedbackPresenterTests
{
[Test]
public void PresenterPrimesRetainedEventsAndEmitsEachNewIdentityOnce()
{
var presenter = new EveUnityFeedbackPresenter();
var emitted = new List<EveUnityFeedbackEvent>();
presenter.FeedbackAvailable += emitted.Add;
Assert.That(presenter.Apply(Projection(Event("old", "projectile.impact", 4))), Is.Zero);
Assert.That(emitted, Is.Empty, "initial retained events must not replay on connect");
var next = Projection(Event("old", "projectile.impact", 4), Event("new", "entity.damaged", 5));
Assert.That(presenter.Apply(next), Is.EqualTo(1));
Assert.That(emitted.Count, Is.EqualTo(1));
Assert.That(emitted[0].EventId, Is.EqualTo("new"));
Assert.That(emitted[0].Kind, Is.EqualTo("entity.damaged"));
Assert.That(emitted[0].FrameId, Is.EqualTo(5));
Assert.That(presenter.Apply(next), Is.Zero, "surface refresh must not replay an event identity");
Assert.That(emitted.Count, Is.EqualTo(1));
}
private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] events) =>
new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null,
Node("root", "surface", Node("feedback", "feedback.stream", events)));
private static EveUnitySceneNode Event(string id, string kind, long frame) =>
new EveUnitySceneNode(id, "feedback.event", "scene-node",
new Dictionary<string, string>(StringComparer.Ordinal) { ["eventId"] = id, ["eventKind"] = kind, ["frameId"] = frame.ToString() },
Empty(), Empty(), 0, 0, Array.Empty<EveUnitySceneEmbeddedDocumentSlot>(), null, Array.Empty<EveUnitySceneNode>());
private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) =>
new EveUnitySceneNode(id, kind, "scene-node", Empty(), 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: 6c518b1a24658e947bfac8a10a853b8e

View file

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
#nullable enable
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityShotReceiptPresenterTests
{
[Test]
public void PresenterPrimesRetainedShotsAndEmitsNewTrajectoryOnce()
{
var presenter = new EveUnityShotReceiptPresenter();
var emitted = new List<EveUnityShotReceipt>();
presenter.ShotAvailable += emitted.Add;
Assert.That(presenter.Apply(Projection(Shot("old", "0,0", "10,5", false))), Is.Zero);
Assert.That(emitted, Is.Empty);
var next = Projection(
Shot("old", "0,0", "10,5", false),
Shot("new", "2,3", "20,30", true));
Assert.That(presenter.Apply(next), Is.EqualTo(1));
Assert.That(emitted, Has.Count.EqualTo(1));
Assert.That(emitted[0].ShotId, Is.EqualTo("new"));
Assert.That(emitted[0].Hit, Is.True);
Assert.That(emitted[0].Origin.X, Is.EqualTo(2));
Assert.That(emitted[0].Origin.Z, Is.EqualTo(3));
Assert.That(emitted[0].Endpoint.X, Is.EqualTo(20));
Assert.That(emitted[0].Endpoint.Z, Is.EqualTo(30));
Assert.That(emitted[0].DurationSeconds, Is.EqualTo(0.25));
Assert.That(emitted[0].PresentationKind, Is.EqualTo("test-laser"));
Assert.That(presenter.Apply(next), Is.Zero);
}
private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] shots) =>
new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null,
Node("root", "surface", Node("shots", "shot.receipt-stream", shots)));
private static EveUnitySceneNode Shot(string id, string origin, string endpoint, bool hit) =>
new EveUnitySceneNode(id, "shot.receipt", "scene-node",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["shotId"] = id,
["frameId"] = "12",
["zoneIndex"] = "0",
["sourceEntityIndex"] = "1",
["targetEntityIndex"] = "2",
["hit"] = hit.ToString(),
["outcome"] = hit ? "hit" : "miss",
["origin"] = origin,
["endpoint"] = endpoint,
["presentationDuration"] = "0.25",
["presentationKind"] = "test-laser"
}, Empty(), Empty(), 0, 0, Array.Empty<EveUnitySceneEmbeddedDocumentSlot>(), null,
Array.Empty<EveUnitySceneNode>());
private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) =>
new EveUnitySceneNode(id, kind, "scene-node", Empty(), 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: 2fd315330f9c4acbac32249d6dcccb38

View file

@ -24,6 +24,9 @@
"liveProviderTransportBridge",
"livePlayableWorldClient",
"providerCommandReceipts",
"providerFeedbackEvents",
"providerShotReceipts",
"shotTrajectoryPresentation",
"providerAssetManifestDocumentSource",
"playableWorldClientHost",
"playableWorldClientBootstrap",
@ -108,6 +111,9 @@
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",