This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
EveUnity/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs
2026-07-16 04:13:28 +01:00

380 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public interface IEveUnityGameObjectAssetProvider
{
GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset);
}
public interface IEveUnityNativeAssetProvider : IEveUnityGameObjectAssetProvider
{
UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType);
}
public interface IEveUnityNativeAssetMetadataProvider
{
bool TryResolveAssetMetadata(
EveUnityPlayableWorldAssetBinding asset,
out IReadOnlyDictionary<string, string> metadata);
}
public sealed class EveUnityGameObjectPlayableWorldSceneSink :
IEveUnityPlayableWorldSceneSink,
IEveUnityEntityGenerationSink,
IEveUnityPresentedEntityRegistry,
IDisposable
{
private readonly Transform _root;
private readonly IEveUnityGameObjectAssetProvider _assetProvider;
private readonly Dictionary<string, GameObject> _instances = new Dictionary<string, GameObject>(StringComparer.Ordinal);
private IReadOnlyDictionary<string, EveUnityPresentedEntityHandle> _presentedById =
new Dictionary<string, EveUnityPresentedEntityHandle>(StringComparer.Ordinal);
private IReadOnlyDictionary<int, EveUnityPresentedEntityHandle> _presentedByIndex =
new Dictionary<int, EveUnityPresentedEntityHandle>();
public EveUnityGameObjectPlayableWorldSceneSink(
Transform root,
IEveUnityGameObjectAssetProvider assetProvider)
{
_root = root != null ? root : throw new ArgumentNullException(nameof(root));
_assetProvider = assetProvider ?? throw new ArgumentNullException(nameof(assetProvider));
}
public int ActiveEntityCount => _instances.Count;
public EveUnityPresentedEntityGeneration? CurrentGeneration { get; private set; }
public bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle) =>
_presentedById.TryGetValue(entityId ?? "", out handle!);
public bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle) =>
_presentedByIndex.TryGetValue(sourceIndex, out handle!);
public void ConfigureWorld(EveUnityPlayableWorldProjection world)
{
if (world == null) throw new ArgumentNullException(nameof(world));
if (!string.IsNullOrWhiteSpace(world.WorldRootId))
_root.name = world.WorldRootId;
}
public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
if (asset == null) throw new ArgumentNullException(nameof(asset));
if (string.IsNullOrWhiteSpace(entity.EntityId))
return;
GameObject instance;
if (!_instances.TryGetValue(entity.EntityId, out instance) || instance == null)
{
instance = InstantiateEntity(entity, asset);
_instances[entity.EntityId] = instance;
}
else
{
var marker = instance.GetComponent<EveUnityPlayableWorldEntityMarker>();
if (marker != null && !string.Equals(marker.AssetRef, asset.AssetRef, StringComparison.Ordinal))
{
DestroyInstance(instance);
instance = InstantiateEntity(entity, asset);
_instances[entity.EntityId] = instance;
}
}
ApplyEntity(instance, entity, asset);
}
public void ApplyGeneration(EveUnityPresentedEntityGeneration generation)
{
if (generation == null) throw new ArgumentNullException(nameof(generation));
if (generation.Entities.Any(entity => string.IsNullOrWhiteSpace(entity.EntityId)))
throw new InvalidOperationException("Presented entity generations require stable entity ids.");
var factsById = generation.Entities.ToDictionary(entity => entity.EntityId, StringComparer.Ordinal);
var factsByIndex = generation.Entities.ToDictionary(entity => entity.SourceIndex);
GameObject? staging = null;
var created = new Dictionary<string, GameObject>(StringComparer.Ordinal);
var nextInstances = new Dictionary<string, GameObject>(StringComparer.Ordinal);
try
{
foreach (var fact in generation.Entities)
{
if (_instances.TryGetValue(fact.EntityId, out var existing) && existing != null)
{
nextInstances.Add(fact.EntityId, existing);
continue;
}
staging ??= new GameObject($"{_root.name}.generation.{generation.ProducerEpoch}.{generation.Sequence}");
if (staging.transform.parent == null)
{
staging.SetActive(false);
staging.transform.SetParent(_root, false);
}
var entity = fact.ToPlayableWorldEntity();
var asset = new EveUnityPlayableWorldAssetBinding(
fact.AssetRef,
fact.EntityKind,
string.IsNullOrWhiteSpace(fact.AssetRef) ? "unity-generated-placeholder" : "provider-asset-ref");
var instance = InstantiateEntity(entity, asset, staging.transform);
created.Add(fact.EntityId, instance);
nextInstances.Add(fact.EntityId, instance);
}
foreach (var fact in generation.Entities)
{
var entity = fact.ToPlayableWorldEntity();
var asset = new EveUnityPlayableWorldAssetBinding(
fact.AssetRef,
fact.EntityKind,
string.IsNullOrWhiteSpace(fact.AssetRef) ? "unity-generated-placeholder" : "provider-asset-ref");
ApplyEntity(nextInstances[fact.EntityId], entity, asset);
}
var nextById = factsById.ToDictionary(
pair => pair.Key,
pair => new EveUnityPresentedEntityHandle(pair.Value, nextInstances[pair.Key].transform),
StringComparer.Ordinal);
var nextByIndex = factsByIndex.ToDictionary(
pair => pair.Key,
pair => nextById[pair.Value.EntityId]);
foreach (var pair in _instances)
if (!nextInstances.ContainsKey(pair.Key) && pair.Value != null)
DestroyInstance(pair.Value);
_instances.Clear();
foreach (var pair in nextInstances)
{
if (created.ContainsKey(pair.Key))
{
pair.Value.transform.SetParent(_root, true);
pair.Value.SetActive(true);
}
_instances.Add(pair.Key, pair.Value);
}
_presentedById = nextById;
_presentedByIndex = nextByIndex;
CurrentGeneration = generation;
}
catch
{
foreach (var instance in created.Values)
if (instance != null) DestroyInstance(instance);
throw;
}
finally
{
if (staging != null)
DestroyInstance(staging);
}
}
public void RemoveEntity(string entityId)
{
if (string.IsNullOrWhiteSpace(entityId))
return;
GameObject instance;
if (!_instances.TryGetValue(entityId, out instance))
return;
_instances.Remove(entityId);
if (instance != null)
DestroyInstance(instance);
}
public void Dispose()
{
foreach (var instance in _instances.Values)
if (instance != null)
{
instance.transform.SetParent(null, false);
instance.SetActive(false);
DestroyInstance(instance);
}
_instances.Clear();
_presentedById = new Dictionary<string, EveUnityPresentedEntityHandle>(StringComparer.Ordinal);
_presentedByIndex = new Dictionary<int, EveUnityPresentedEntityHandle>();
CurrentGeneration = null;
}
private GameObject InstantiateEntity(
EveUnityPlayableWorldEntity entity,
EveUnityPlayableWorldAssetBinding asset,
Transform? parent = null)
{
parent ??= _root;
var prefab = _assetProvider.ResolvePrefab(asset);
GameObject instance;
if (prefab != null)
{
instance = new GameObject(entity.EntityId);
instance.transform.SetParent(parent, false);
var visual = UnityEngine.Object.Instantiate(prefab, instance.transform);
visual.transform.localPosition = Vector3.zero;
visual.transform.localRotation = Quaternion.identity;
}
else
{
instance = GameObject.CreatePrimitive(PrimitiveType.Capsule);
}
if (instance.transform.parent != parent)
instance.transform.SetParent(parent, false);
return instance;
}
private static void ApplyEntity(
GameObject instance,
EveUnityPlayableWorldEntity entity,
EveUnityPlayableWorldAssetBinding asset)
{
instance.name = string.IsNullOrWhiteSpace(entity.Label)
? entity.EntityId
: $"{entity.EntityId} ({entity.Label})";
instance.transform.localPosition = new Vector3(entity.PositionX, entity.PositionY, entity.PositionZ);
instance.transform.localRotation = Quaternion.Euler(0f, entity.RotationY, 0f);
instance.transform.localScale = Vector3.one * (entity.Radius > 0f ? entity.Radius : 1f);
var marker = instance.GetComponent<EveUnityPlayableWorldEntityMarker>();
if (marker == null)
marker = instance.AddComponent<EveUnityPlayableWorldEntityMarker>();
marker.Apply(entity, asset);
if (entity.Props.TryGetValue("presentationState", out var presentationState) &&
!string.IsNullOrWhiteSpace(presentationState))
{
var semanticPresentation = instance.GetComponent<EveUnitySemanticEntityPresentation>();
if (semanticPresentation == null)
semanticPresentation = instance.AddComponent<EveUnitySemanticEntityPresentation>();
semanticPresentation.Apply(entity.Props);
}
}
private static void DestroyInstance(GameObject instance)
{
if (Application.isPlaying)
UnityEngine.Object.Destroy(instance);
else
UnityEngine.Object.DestroyImmediate(instance);
}
}
public sealed class EveUnitySemanticEntityPresentation : MonoBehaviour
{
private static readonly int Emission = Shader.PropertyToID("_Emission");
private static readonly int EmissionColor = Shader.PropertyToID("_EmissionColor");
private MaterialPropertyBlock? _properties;
private Renderer[] _renderers = Array.Empty<Renderer>();
private string _state = "";
private float _activePulseSeconds = 1f;
private float _triggeredPulseSeconds = 0.25f;
private float _activeEmission = 1f;
private float _triggeredEmission = 4f;
public string PresentationState => _state;
public void Apply(IReadOnlyDictionary<string, string> props)
{
props ??= new Dictionary<string, string>(StringComparer.Ordinal);
_state = Read(props, "presentationState");
_activePulseSeconds = Positive(ReadFloat(props, "activePulseSeconds", _activePulseSeconds), 1f);
_triggeredPulseSeconds = Positive(ReadFloat(props, "triggeredPulseSeconds", _triggeredPulseSeconds), 0.25f);
_activeEmission = Math.Max(0f, ReadFloat(props, "activeEmission", _activeEmission));
_triggeredEmission = Math.Max(0f, ReadFloat(props, "triggeredEmission", _triggeredEmission));
_renderers = GetComponentsInChildren<Renderer>(true);
ApplyAt(Time.time);
}
public void ApplyAt(float timeSeconds)
{
var triggered = string.Equals(_state, "triggered", StringComparison.Ordinal);
var active = triggered || string.Equals(_state, "active", StringComparison.Ordinal);
var period = triggered ? _triggeredPulseSeconds : _activePulseSeconds;
var peak = triggered ? _triggeredEmission : _activeEmission;
var envelope = active
? Mathf.Lerp(0.1f, 1f, Mathf.Abs(Mathf.Cos(Mathf.PI * timeSeconds / period)))
: 0f;
var value = peak * envelope;
_properties ??= new MaterialPropertyBlock();
foreach (var renderer in _renderers)
{
if (renderer == null) continue;
renderer.GetPropertyBlock(_properties);
_properties.SetFloat(Emission, value);
_properties.SetColor(EmissionColor, Color.white * value);
renderer.SetPropertyBlock(_properties);
}
}
private void Update() => ApplyAt(Time.time);
private static string Read(IReadOnlyDictionary<string, string> props, string key) =>
props.TryGetValue(key, out var value) ? value ?? "" : "";
private static float ReadFloat(IReadOnlyDictionary<string, string> props, string key, float fallback) =>
props.TryGetValue(key, out var value) && float.TryParse(value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var parsed)
? parsed
: fallback;
private static float Positive(float value, float fallback) => value > 0f ? value : fallback;
}
public sealed class EveUnityResourcesAssetProvider : IEveUnityNativeAssetProvider
{
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
{
if (asset == null) throw new ArgumentNullException(nameof(asset));
var resourcesPath = EveUnityPlayableWorldAssetManifestEntry.NormalizeResourcesPath(asset.AssetRef);
return string.IsNullOrWhiteSpace(resourcesPath)
? null
: Resources.Load<GameObject>(resourcesPath);
}
public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType)
{
if (asset == null) throw new ArgumentNullException(nameof(asset));
if (assetType == null) throw new ArgumentNullException(nameof(assetType));
var resourcesPath = EveUnityPlayableWorldAssetManifestEntry.NormalizeResourcesPath(asset.AssetRef);
return string.IsNullOrWhiteSpace(resourcesPath) ? null : Resources.Load(resourcesPath, assetType);
}
}
public sealed class EveUnityPlayableWorldEntityMarker : MonoBehaviour
{
public string EntityId = "";
public string NodeId = "";
public string EntityKind = "";
public string Label = "";
public string Faction = "";
public string AssetRef = "";
public string PresentationKind = "";
public bool Selectable;
public bool Controllable;
public void Apply(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
if (asset == null) throw new ArgumentNullException(nameof(asset));
EntityId = entity.EntityId;
NodeId = entity.NodeId;
EntityKind = entity.EntityKind;
Label = entity.Label;
Faction = entity.Faction;
AssetRef = asset.AssetRef;
PresentationKind = asset.PresentationKind;
Selectable = entity.Selectable;
Controllable = entity.Controllable;
}
}
}