From e4d83f342f1e6e00b0839d668919602ae732bf38 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 10:31:49 +0100 Subject: [PATCH] Lower provider-authored continuous beams --- TestProject/Packages/packages-lock.json | 13 +- .../org.gamecult.eve.unity-scene/README.md | 5 + .../Runtime/EveUnityBeamPresentation.cs | 67 ++++++++ .../Runtime/EveUnityBeamPresentation.cs.meta | 2 + .../EveUnityBeamPresentationRenderer.cs | 148 ++++++++++++++++++ .../EveUnityBeamPresentationRenderer.cs.meta | 2 + .../EveUnityPlayableWorldClientHost.cs | 3 + .../EveFieldsUnitySceneProjectionTests.cs | 2 +- .../Editor/EveUnityBeamPresentationTests.cs | 138 ++++++++++++++++ .../EveUnityBeamPresentationTests.cs.meta | 2 + .../org.gamecult.eve.unity-scene/package.json | 5 +- 11 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs.meta diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index c270403..7a5b662 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -48,10 +48,11 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.9", + "org.gamecult.cultlib": "1.0.13", "org.gamecult.eve.plugin-fields": "0.1.1", - "org.gamecult.eve.surface": "0.2.0", - "com.unity.mathematics": "1.3.2" + "org.gamecult.eve.surface": "0.2.1", + "com.unity.mathematics": "1.3.2", + "com.unity.modules.particlesystem": "1.0.0" } }, "org.gamecult.eve.unity-uitoolkit": { @@ -92,6 +93,12 @@ "source": "builtin", "dependencies": {} }, + "com.unity.modules.particlesystem": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.physics": { "version": "1.0.0", "depth": 0, diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 5ad692f..dd93898 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -49,6 +49,11 @@ new deduplicated shot receipt from the advertised controlled source to the advertised selection with authoritative applied or shield-absorbed damage. SoA remains the only transform authority; the presentation component never duplicates bodies or computes combat outcomes. +Continuous `beam.presentation` nodes bind provider-authored effect assets to +the current presented-entity generation. The generic beam renderer follows the +advertised source transform, applies provider-owned power to particle emission, +and reconciles asset lifecycles by semantic role. It never raycasts, applies +force, infers contact, collects cargo, or emits gameplay receipts. The reusable `LightningCompute` renderer and its shader/material bundle live in this runtime package with their original Unity GUIDs. Provider asset bundles may therefore reference the effect without importing Aetheria gameplay code. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs new file mode 100644 index 0000000..fc11c16 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityBeamPresentation + { + public const string SourceForwardDirection = "source-forward.v1"; + + public EveUnityBeamPresentation(EveUnitySceneNode node) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + Id = node.Id ?? ""; + SourceEntityId = Get(node, "sourceEntityId"); + AssetRole = Get(node, "assetRole"); + DirectionMode = Get(node, "directionMode"); + if (string.IsNullOrWhiteSpace(DirectionMode)) DirectionMode = SourceForwardDirection; + RenderChannel = Get(node, "renderChannel"); + ActivationActionId = Get(node, "activationActionId"); + Power = NonNegative(node, "power", 0f); + ActivationThreshold = NonNegative(node, "activationThreshold", 0.01f); + Radius = NonNegative(node, "radius", 0f); + MaximumDistance = NonNegative(node, "maximumDistance", 0f); + } + + public string Id { get; } + public string SourceEntityId { get; } + public string AssetRole { get; } + public string DirectionMode { get; } + public string RenderChannel { get; } + public string ActivationActionId { get; } + public float Power { get; } + public float ActivationThreshold { get; } + public float Radius { get; } + public float MaximumDistance { get; } + public bool UsesSourceForward => + string.Equals(DirectionMode, SourceForwardDirection, StringComparison.Ordinal); + + public static IReadOnlyList FindAll(EveUnitySceneProjection? projection) + { + var found = new List(); + if (projection != null) FindAll(projection.Root, found); + return found; + } + + private static void FindAll(EveUnitySceneNode node, ICollection found) + { + if (string.Equals(node.ComponentKind, "beam.presentation", StringComparison.Ordinal)) + found.Add(new EveUnityBeamPresentation(node)); + foreach (var child in node.Children) FindAll(child, found); + } + + private static string Get(EveUnitySceneNode node, string key) => + node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static float NonNegative(EveUnitySceneNode node, string key, float fallback) + { + if (!float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || + float.IsNaN(value) || float.IsInfinity(value)) + return fallback; + return Math.Max(0f, value); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs.meta new file mode 100644 index 0000000..4acc86b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3086cd42c94f2624197d19704909f3f5 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs new file mode 100644 index 0000000..c118c3b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityBeamPresentationRenderer : MonoBehaviour + { + private readonly Dictionary _active = new Dictionary(StringComparer.Ordinal); + private EveUnityPlayableWorldClientHost? _host; + private IEveUnityGameObjectAssetProvider? _assetProvider; + + public int ActiveBeamCount => _active.Count; + public int ActiveParticleSystemCount + { + get + { + var count = 0; + foreach (var beam in _active.Values) + if (beam.Root != null) + count += beam.Root.GetComponentsInChildren(true).Length; + return count; + } + } + + public void Bind( + EveUnityPlayableWorldClientHost host, + IEveUnityGameObjectAssetProvider? assetProvider = null) + { + _host = host != null ? host : throw new ArgumentNullException(nameof(host)); + _assetProvider = assetProvider; + } + + public void RefreshNow() + { + Apply( + EveUnityBeamPresentation.FindAll(_host?.ActiveProjection), + _host?.PresentedEntities, + _assetProvider); + } + + public void Apply( + IReadOnlyList presentations, + IEveUnityPresentedEntityRegistry? entities, + IEveUnityGameObjectAssetProvider? assetProvider) + { + var retained = new HashSet(StringComparer.Ordinal); + foreach (var presentation in presentations ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(presentation.Id) || + string.IsNullOrWhiteSpace(presentation.SourceEntityId) || + string.IsNullOrWhiteSpace(presentation.AssetRole) || + !presentation.UsesSourceForward || entities == null || + !entities.TryGetByEntityId(presentation.SourceEntityId, out var source)) + continue; + + retained.Add(presentation.Id); + if (!_active.TryGetValue(presentation.Id, out var beam) || + !string.Equals(beam.SourceEntityId, presentation.SourceEntityId, StringComparison.Ordinal) || + !string.Equals(beam.AssetRole, presentation.AssetRole, StringComparison.Ordinal) || + beam.Root == null) + { + Remove(presentation.Id); + var prefab = assetProvider?.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( + "", presentation.AssetRole, "provider-asset-ref")); + if (prefab == null) continue; + var root = Instantiate(prefab, source.Transform, false); + root.name = "Eve beam " + presentation.Id; + beam = new ActiveBeam(root, presentation.SourceEntityId, presentation.AssetRole); + _active[presentation.Id] = beam; + } + else if (beam.Root.transform.parent != source.Transform) + { + beam.Root.transform.SetParent(source.Transform, false); + } + + var visiblePower = presentation.Power > presentation.ActivationThreshold + ? presentation.Power + : 0f; + foreach (var particles in beam.Root.GetComponentsInChildren(true)) + { + var emission = particles.emission; + emission.rateOverTimeMultiplier = visiblePower; + } + beam.Power = visiblePower; + } + + var stale = new List(); + foreach (var id in _active.Keys) + if (!retained.Contains(id)) stale.Add(id); + foreach (var id in stale) Remove(id); + } + + public bool TryGetPower(string presentationId, out float power) + { + if (_active.TryGetValue(presentationId ?? "", out var beam) && beam.Root != null) + { + power = beam.Power; + return true; + } + power = 0f; + return false; + } + + private void LateUpdate() => RefreshNow(); + + private void Remove(string id) + { + if (!_active.TryGetValue(id, out var beam)) return; + _active.Remove(id); + if (beam.Root != null) + { + if (Application.isPlaying) Destroy(beam.Root); + else DestroyImmediate(beam.Root); + } + } + + private void OnDestroy() + { + foreach (var beam in _active.Values) + if (beam.Root != null) + { + if (Application.isPlaying) Destroy(beam.Root); + else DestroyImmediate(beam.Root); + } + _active.Clear(); + _host = null; + _assetProvider = null; + } + + private sealed class ActiveBeam + { + public ActiveBeam(GameObject root, string sourceEntityId, string assetRole) + { + Root = root; + SourceEntityId = sourceEntityId; + AssetRole = assetRole; + } + + public GameObject Root { get; } + public string SourceEntityId { get; } + public string AssetRole { get; } + public float Power { get; set; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs.meta new file mode 100644 index 0000000..ecae7e9 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f91c99c24bc0e20488fa03daef2eec06 \ 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 17741f4..22848d1 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -103,6 +103,9 @@ namespace GameCult.Eve.UnityScene var aimPresentation = GetComponent(); if (aimPresentation == null) aimPresentation = gameObject.AddComponent(); aimPresentation.Bind(this); + var beamPresentation = GetComponent(); + if (beamPresentation == null) beamPresentation = gameObject.AddComponent(); + beamPresentation.Bind(this, Runtime.GameObjectAssetProvider); var thermal = GetComponent(); var thermalHud = GetComponent(); if (thermalHud == null) thermalHud = gameObject.AddComponent(); diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs index f3e130f..959b571 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs @@ -49,7 +49,7 @@ namespace GameCult.Eve.UnityScene.Tests var previous = RenderTexture.active; RenderTexture.active = output; - var readback = new Texture2D(64, 64, TextureFormat.RGBAFloat, false, true); + var readback = new Texture2D(64, 64, TextureFormat.RGBA32, false, true); readback.ReadPixels(new Rect(0, 0, 64, 64), 0, 0); readback.Apply(); RenderTexture.active = previous; diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs new file mode 100644 index 0000000..99e965f --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityBeamPresentationTests + { + [Test] + public void ProjectionPreservesProviderBeamSourceAssetAndFossilShape() + { + var beam = EveUnityBeamPresentation.FindAll(Projection(Node( + "tractor", "beam.presentation", new Dictionary(StringComparer.Ordinal) + { + ["sourceEntityId"] = "run.zone.0.entity.4", + ["assetRole"] = "effect.beam.tractor", + ["directionMode"] = "source-forward.v1", + ["renderChannel"] = "world.effects", + ["activationActionId"] = "pilot.scoop", + ["power"] = "0.625", + ["activationThreshold"] = "0.01", + ["radius"] = "25", + ["maximumDistance"] = "75" + }))).Single(); + + Assert.That(beam.SourceEntityId, Is.EqualTo("run.zone.0.entity.4")); + Assert.That(beam.AssetRole, Is.EqualTo("effect.beam.tractor")); + Assert.That(beam.ActivationActionId, Is.EqualTo("pilot.scoop")); + Assert.That(beam.UsesSourceForward, Is.True); + Assert.That(beam.Power, Is.EqualTo(0.625f).Within(0.0001f)); + Assert.That(beam.Radius, Is.EqualTo(25f)); + Assert.That(beam.MaximumDistance, Is.EqualTo(75f)); + } + + [Test] + public void RendererReconcilesProviderPrefabAgainstPresentedSourceWithoutPhysics() + { + var host = new GameObject("beam-test-host"); + var source = new GameObject("beam-test-source"); + var prefab = new GameObject("beam-test-prefab"); + prefab.AddComponent(); + var renderer = host.AddComponent(); + var registry = new Registry("source", source.transform); + var provider = new Provider(prefab); + try + { + renderer.Apply(new[] { Presentation(0.5f) }, registry, provider); + + Assert.That(provider.LastRole, Is.EqualTo("effect.beam.tractor")); + Assert.That(renderer.ActiveBeamCount, Is.EqualTo(1)); + Assert.That(renderer.TryGetPower("tractor", out var power), Is.True); + Assert.That(power, Is.EqualTo(0.5f).Within(0.0001f)); + var instance = source.transform.GetChild(0).gameObject; + Assert.That(instance.GetComponent().emission.rateOverTimeMultiplier, + Is.EqualTo(0.5f).Within(0.0001f)); + Assert.That(instance.GetComponentsInChildren(true), Is.Empty); + Assert.That(instance.GetComponentsInChildren(true), Is.Empty); + + renderer.Apply(new[] { Presentation(0.005f) }, registry, provider); + Assert.That(renderer.TryGetPower("tractor", out power), Is.True); + Assert.That(power, Is.Zero); + + renderer.Apply(Array.Empty(), registry, provider); + Assert.That(renderer.ActiveBeamCount, Is.Zero); + } + finally + { + UnityEngine.Object.DestroyImmediate(host); + UnityEngine.Object.DestroyImmediate(source); + UnityEngine.Object.DestroyImmediate(prefab); + } + } + + private static EveUnityBeamPresentation Presentation(float power) => new EveUnityBeamPresentation(Node( + "tractor", "beam.presentation", new Dictionary(StringComparer.Ordinal) + { + ["sourceEntityId"] = "source", + ["assetRole"] = "effect.beam.tractor", + ["directionMode"] = "source-forward.v1", + ["power"] = power.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["activationThreshold"] = "0.01" + })); + + private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] children) => + new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null, + Node("root", "surface", Empty(), children)); + + private static EveUnitySceneNode Node( + string id, + string kind, + IReadOnlyDictionary props, + params EveUnitySceneNode[] children) => + new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0, + Array.Empty(), null, children); + + private static IReadOnlyDictionary Empty() => + new Dictionary(StringComparer.Ordinal); + + private sealed class Registry : IEveUnityPresentedEntityRegistry + { + private readonly EveUnityPresentedEntityHandle _handle; + public Registry(string entityId, Transform transform) + { + _handle = new EveUnityPresentedEntityHandle( + new EveUnityPresentedEntity(0, entityId, "ship", "", "", Vector3.zero, 0, Vector3.zero, + 1, 1, 0, 0, true, true, ""), + transform); + } + public EveUnityPresentedEntityGeneration? CurrentGeneration => null; + public bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle) + { + handle = _handle; + return string.Equals(entityId, _handle.Entity.EntityId, StringComparison.Ordinal); + } + public bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle) + { + handle = _handle; + return sourceIndex == _handle.Entity.SourceIndex; + } + } + + private sealed class Provider : IEveUnityGameObjectAssetProvider + { + private readonly GameObject _prefab; + public Provider(GameObject prefab) { _prefab = prefab; } + public string LastRole { get; private set; } = ""; + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) + { + LastRole = asset.EntityKind; + return _prefab; + } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs.meta new file mode 100644 index 0000000..84e1c67 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7cd6b9cdacbecf14593d6ca2e0044849 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index f19cb5a..ac92254 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,14 +1,15 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.27", + "version": "0.3.28", "unity": "2021.3", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { "org.gamecult.cultlib": "1.0.13", "org.gamecult.eve.plugin-fields": "0.1.1", "org.gamecult.eve.surface": "0.2.1", - "com.unity.mathematics": "1.3.2" + "com.unity.mathematics": "1.3.2", + "com.unity.modules.particlesystem": "1.0.0" }, "keywords": [ "Eve",