From 739cd9862fc519b0dc2024b5f6886d08e8b0dc4e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 01:15:51 +0100 Subject: [PATCH] Lower provider combat feedback in EveUnity --- .../org.gamecult.eve.unity-scene/README.md | 7 + .../Runtime/EveUnityFeedbackPresenter.cs | 70 ++++++++++ .../Runtime/EveUnityFeedbackPresenter.cs.meta | 2 + .../EveUnityPlayableWorldClientHost.cs | 20 +++ .../EveUnityPlayableWorldLiveClient.cs | 17 +++ .../Runtime/EveUnityPlayableWorldRuntime.cs | 12 ++ .../Runtime/EveUnityShotReceiptPresenter.cs | 120 ++++++++++++++++++ .../EveUnityShotReceiptPresenter.cs.meta | 2 + .../Runtime/EveUnityShotTrajectoryRenderer.cs | 118 +++++++++++++++++ .../EveUnityShotTrajectoryRenderer.cs.meta | 2 + .../Editor/EveUnityFeedbackPresenterTests.cs | 47 +++++++ .../EveUnityFeedbackPresenterTests.cs.meta | 2 + .../EveUnityShotReceiptPresenterTests.cs | 66 ++++++++++ .../EveUnityShotReceiptPresenterTests.cs.meta | 2 + .../eve-runtime-capability.json | 6 + 15 files changed, 493 insertions(+) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs.meta diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 43d7650..e7d4cde 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. +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 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs new file mode 100644 index 0000000..1f189fe --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs @@ -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 _seen = new HashSet(StringComparer.Ordinal); + private bool _primed; + public event Action? FeedbackAvailable; + + public int Apply(EveUnitySceneProjection projection) + { + if (projection == null) throw new ArgumentNullException(nameof(projection)); + var pending = new List(); + 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 values) + { + if (string.Equals(node.ComponentKind, "feedback.event", StringComparison.Ordinal)) values.Add(new EveUnityFeedbackEvent(node)); + foreach (var child in node.Children) Collect(child, values); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs.meta new file mode 100644 index 0000000..afd31a4 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 28dc73d4047dbbf4f926612650a72592 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index 30da84a..f30a134 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -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? FeedbackAvailable; + public event Action? ShotAvailable; + public Transform SceneRoot => sceneRoot == null ? transform : sceneRoot; public bool ConnectOnEnable @@ -73,6 +77,14 @@ namespace GameCult.Eve.UnityScene Optional(assetManifestDocuments), Optional(receiptSource), Optional(fallbackAssetProvider)); + Runtime.FeedbackAvailable += OnFeedbackAvailable; + Runtime.ShotAvailable += OnShotAvailable; + if (renderShotTrajectories) + { + var trajectories = GetComponent(); + if (trajectories == null) trajectories = gameObject.AddComponent(); + 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) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index 8fc1a98..4c7eebb 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -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 _pendingReceipts = new List(); private bool _connected; @@ -42,6 +44,17 @@ namespace GameCult.Eve.UnityScene public string SourcePointer => _connection.SourcePointer; public event Action? ReceiptAvailable; + public event Action? FeedbackAvailable + { + add { _feedback.FeedbackAvailable += value; } + remove { _feedback.FeedbackAvailable -= value; } + } + + public event Action? 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(); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs index 3dd56e4..4af8a43 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -59,6 +59,18 @@ namespace GameCult.Eve.UnityScene remove { _client.ReceiptAvailable -= value; } } + public event Action? FeedbackAvailable + { + add { _client.FeedbackAvailable += value; } + remove { _client.FeedbackAvailable -= value; } + } + + public event Action? ShotAvailable + { + add { _client.ShotAvailable += value; } + remove { _client.ShotAvailable -= value; } + } + public static EveUnityPlayableWorldRuntime CreateForGameObjectScene( Transform root, IEveUnitySceneProviderSurfaceDocumentSource surfaceDocuments, diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs new file mode 100644 index 0000000..873bfa3 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs @@ -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 _seen = new HashSet(StringComparer.Ordinal); + private bool _primed; + + public event Action? ShotAvailable; + + public int Apply(EveUnitySceneProjection projection) + { + if (projection == null) throw new ArgumentNullException(nameof(projection)); + var receipts = new List(); + 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 receipts) + { + if (string.Equals(node.ComponentKind, "shot.receipt", StringComparison.Ordinal)) + receipts.Add(new EveUnityShotReceipt(node)); + foreach (var child in node.Children) Collect(child, receipts); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs.meta new file mode 100644 index 0000000..e6f7a09 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7af61307fcb149fc96d9f5d67f5aa1f3 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs new file mode 100644 index 0000000..035e953 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs @@ -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 _active = new List(); + 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(); + 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; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs.meta new file mode 100644 index 0000000..6e5a02c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 96ad4b7918444a82bc1ba2fd37ac2e39 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs new file mode 100644 index 0000000..e4bd612 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs @@ -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(); + 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(StringComparer.Ordinal) { ["eventId"] = id, ["eventKind"] = kind, ["frameId"] = frame.ToString() }, + Empty(), Empty(), 0, 0, Array.Empty(), null, Array.Empty()); + + private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) => + new EveUnitySceneNode(id, kind, "scene-node", Empty(), Empty(), Empty(), 0, 0, + Array.Empty(), null, children); + + private static IReadOnlyDictionary Empty() => new Dictionary(StringComparer.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs.meta new file mode 100644 index 0000000..8214f51 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6c518b1a24658e947bfac8a10a853b8e \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs new file mode 100644 index 0000000..b64cfbf --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs @@ -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(); + 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(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(), null, + Array.Empty()); + + private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) => + new EveUnitySceneNode(id, kind, "scene-node", Empty(), Empty(), Empty(), 0, 0, + Array.Empty(), null, children); + + private static IReadOnlyDictionary Empty() => + new Dictionary(StringComparer.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs.meta new file mode 100644 index 0000000..3a8af2c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2fd315330f9c4acbac32249d6dcccb38 diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json index 4175976..122968b 100644 --- a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -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",