From 580af5328d4c5db4295432cb918402ce762c960a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 06:16:16 +0100 Subject: [PATCH] Lower thermal presentation generically --- .../EveUnityPlayableWorldClientHost.cs | 3 + .../Runtime/EveUnityThermalPresentation.cs | 192 ++++++++++++++++++ .../EveUnityThermalPresentation.cs.meta | 2 + .../EveUnityThermalPresentationTests.cs | 86 ++++++++ .../EveUnityThermalPresentationTests.cs.meta | 2 + 5 files changed, 285 insertions(+) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs.meta diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index c6592b6..7ec45de 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -88,6 +88,9 @@ namespace GameCult.Eve.UnityScene var feedbackEffects = GetComponent(); if (feedbackEffects == null) feedbackEffects = gameObject.AddComponent(); feedbackEffects.Bind(this, Runtime.GameObjectAssetProvider); + var thermal = GetComponent(); + if (thermal == null) thermal = gameObject.AddComponent(); + thermal.Bind(this); return Runtime.Connect(); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs new file mode 100644 index 0000000..df9a526 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public interface IEveUnityThermalPresentationSink + { + void ApplyThermalPresentation(EveUnityThermalPresentationFrame frame); + } + + public readonly struct EveUnityThermalPresentationFrame + { + public EveUnityThermalPresentationFrame(string entityId, float cockpitTemperature, + float heatstroke, float hypothermia, bool heatstrokeRisk, bool hypothermiaRisk, + float heatstrokeWeight, float severeHeatstrokeWeight, float hypothermiaWeight, + float severeHypothermiaWeight, string deathCause, float deathWeight) + { + EntityId = entityId ?? ""; + CockpitTemperature = cockpitTemperature; + Heatstroke = heatstroke; + Hypothermia = hypothermia; + HeatstrokeRisk = heatstrokeRisk; + HypothermiaRisk = hypothermiaRisk; + HeatstrokeWeight = heatstrokeWeight; + SevereHeatstrokeWeight = severeHeatstrokeWeight; + HypothermiaWeight = hypothermiaWeight; + SevereHypothermiaWeight = severeHypothermiaWeight; + DeathCause = deathCause ?? ""; + DeathWeight = deathWeight; + } + + public string EntityId { get; } + public float CockpitTemperature { get; } + public float Heatstroke { get; } + public float Hypothermia { get; } + public bool HeatstrokeRisk { get; } + public bool HypothermiaRisk { get; } + public float HeatstrokeWeight { get; } + public float SevereHeatstrokeWeight { get; } + public float HypothermiaWeight { get; } + public float SevereHypothermiaWeight { get; } + public string DeathCause { get; } + public float DeathWeight { get; } + } + + public sealed class EveUnityThermalPresentationState + { + private string _deathEventId = ""; + private string _deathCause = ""; + private float _deathStartedAt = float.NegativeInfinity; + + public EveUnityThermalPresentationFrame Apply( + EveUnityPlayableWorldProjection? world, + EveUnitySceneProjection? projection, + float timeSeconds) + { + var entity = ResolvePlayer(world); + var props = entity?.Props ?? EmptyProps; + var death = LatestThermalDeath(projection); + if (death.HasValue && !string.Equals(_deathEventId, death.Value.EventId, StringComparison.Ordinal)) + { + _deathEventId = death.Value.EventId; + _deathCause = death.Value.Cause; + _deathStartedAt = death.Value.FrameId >= death.Value.CurrentFrameId + ? timeSeconds + : float.NegativeInfinity; + } + else if (!death.HasValue && entity != null) + { + _deathEventId = ""; + _deathCause = ""; + _deathStartedAt = float.NegativeInfinity; + } + + var duration = Positive(Float(props, "deathTransitionSeconds", 1f), 1f); + var deathWeight = string.IsNullOrWhiteSpace(_deathCause) ? 0f : + float.IsNegativeInfinity(_deathStartedAt) ? 1f : + Mathf.Clamp01((timeSeconds - _deathStartedAt) / duration); + var heatstroke = Mathf.Clamp01(Float(props, "heatstroke", 0f)); + var hypothermia = Mathf.Clamp01(Float(props, "hypothermia", 0f)); + var heatWeight = Mathf.Clamp01(Float(props, "heatstrokePostWeight", 0f)); + var severeNormalized = Mathf.Clamp01(Float(props, "severeHeatstrokeWeight", 0f)); + var phaseFloor = Float(props, "heatstrokePhasingFloor", 0f); + var phaseFrequency = Float(props, "heatstrokePhasingFrequency", 5f); + var severeWeight = Mathf.Clamp01(severeNormalized + severeNormalized * (1f - severeNormalized) * + Mathf.Max(phaseFloor, Mathf.Sin(timeSeconds * phaseFrequency))); + var thermalFade = 1f - deathWeight; + if (entity == null && string.Equals(_deathCause, "heatstroke", StringComparison.Ordinal)) + { + heatWeight = thermalFade; + severeWeight = thermalFade; + } + var coldWeight = entity == null && string.Equals(_deathCause, "hypothermia", StringComparison.Ordinal) + ? thermalFade : 0f; + + return new EveUnityThermalPresentationFrame( + entity?.EntityId ?? "", + Float(props, "cockpitTemperature", 0f), heatstroke, hypothermia, + Bool(props, "heatstrokeRisk"), Bool(props, "hypothermiaRisk"), + heatWeight, severeWeight, coldWeight, coldWeight, _deathCause, deathWeight); + } + + private static EveUnityPlayableWorldEntity? ResolvePlayer(EveUnityPlayableWorldProjection? world) + { + if (world == null) return null; + return world.Entities.FirstOrDefault(value => + string.Equals(value.EntityId, world.PlayerEntityId, StringComparison.Ordinal)) ?? + world.Entities.FirstOrDefault(value => value.Controllable); + } + + private static ThermalDeath? LatestThermalDeath(EveUnitySceneProjection? projection) + { + if (projection == null) return null; + ThermalDeath? latest = null; + Visit(projection.Root); + return latest; + + void Visit(EveUnitySceneNode node) + { + if (string.Equals(node.ComponentKind, "feedback.event", StringComparison.Ordinal) && + string.Equals(Read(node.Props, "eventKind"), "entity.destroyed", StringComparison.Ordinal)) + { + var cause = Read(node.Props, "reason"); + if (string.Equals(cause, "heatstroke", StringComparison.Ordinal) || + string.Equals(cause, "hypothermia", StringComparison.Ordinal)) + { + var candidate = new ThermalDeath(Read(node.Props, "eventId"), cause, + Long(node.Props, "frameId", -1), Long(node.Props, "currentFrameId", -1)); + if (!latest.HasValue || candidate.FrameId > latest.Value.FrameId || + candidate.FrameId == latest.Value.FrameId && string.CompareOrdinal( + candidate.EventId, latest.Value.EventId) > 0) + latest = candidate; + } + } + foreach (var child in node.Children) Visit(child); + } + } + + private static readonly IReadOnlyDictionary EmptyProps = + new Dictionary(StringComparer.Ordinal); + private static string Read(IReadOnlyDictionary props, string key) => + props.TryGetValue(key, out var value) ? value ?? "" : ""; + private static float Float(IReadOnlyDictionary props, string key, float fallback) => + float.TryParse(Read(props, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value : fallback; + private static long Long(IReadOnlyDictionary props, string key, long fallback) => + long.TryParse(Read(props, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value : fallback; + private static bool Bool(IReadOnlyDictionary props, string key) => + bool.TryParse(Read(props, key), out var value) && value; + private static float Positive(float value, float fallback) => value > 0f ? value : fallback; + + private readonly struct ThermalDeath + { + public ThermalDeath(string eventId, string cause, long frameId, long currentFrameId) + { EventId = eventId ?? ""; Cause = cause ?? ""; FrameId = frameId; CurrentFrameId = currentFrameId; } + public string EventId { get; } + public string Cause { get; } + public long FrameId { get; } + public long CurrentFrameId { get; } + } + } + + public sealed class EveUnityThermalPresenter : MonoBehaviour + { + private readonly EveUnityThermalPresentationState _state = new EveUnityThermalPresentationState(); + private EveUnityPlayableWorldClientHost? _host; + private IEveUnityThermalPresentationSink[] _sinks = Array.Empty(); + + public EveUnityThermalPresentationFrame LastFrame { get; private set; } + + public void Bind(EveUnityPlayableWorldClientHost host) + { + _host = host != null ? host : throw new ArgumentNullException(nameof(host)); + _sinks = GetComponents().OfType().ToArray(); + ApplyAt(Time.unscaledTime); + } + + public void ApplyAt(float timeSeconds) + { + LastFrame = _state.Apply(_host?.ActiveWorld, _host?.ActiveProjection, timeSeconds); + foreach (var sink in _sinks) sink.ApplyThermalPresentation(LastFrame); + } + + private void Update() => ApplyAt(Time.unscaledTime); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs.meta new file mode 100644 index 0000000..0e851c2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalPresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 434d12dfc31947e9b2c2a91038491b12 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs new file mode 100644 index 0000000..5a0a373 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityThermalPresentationTests + { + [Test] + public void AlivePilotUsesProviderThermalFactsAndOriginalSeverePulse() + { + var state = new EveUnityThermalPresentationState(); + var entity = Entity(new Dictionary(StringComparer.Ordinal) + { + ["cockpitTemperature"] = "340", + ["heatstroke"] = "0.5", + ["hypothermia"] = "0.1", + ["heatstrokeRisk"] = "true", + ["hypothermiaRisk"] = "false", + ["heatstrokePostWeight"] = "1", + ["severeHeatstrokeWeight"] = "0.5", + ["heatstrokePhasingFloor"] = "0", + ["heatstrokePhasingFrequency"] = "5" + }); + + var frame = state.Apply(World(entity), Projection(World(entity), Node("root", "surface", Empty())), + (float)(Math.PI / 10)); + + Assert.That(frame.EntityId, Is.EqualTo("pilot")); + Assert.That(frame.CockpitTemperature, Is.EqualTo(340).Within(0.0001)); + Assert.That(frame.HeatstrokeRisk, Is.True); + Assert.That(frame.HeatstrokeWeight, Is.EqualTo(1).Within(0.0001)); + Assert.That(frame.SevereHeatstrokeWeight, Is.EqualTo(0.75).Within(0.0001)); + Assert.That(frame.DeathWeight, Is.Zero); + } + + [Test] + public void CurrentThermalDeathCrossfadesAndRetainedDeathSettlesOnReconnect() + { + var current = Projection(World(), Node("root", "surface", Empty(), + Node("death", "feedback.event", new Dictionary(StringComparer.Ordinal) + { + ["eventId"] = "death-1", ["eventKind"] = "entity.destroyed", + ["reason"] = "heatstroke", ["frameId"] = "20", ["currentFrameId"] = "20" + }))); + var live = new EveUnityThermalPresentationState(); + var start = live.Apply(World(), current, 10f); + var middle = live.Apply(World(), current, 10.5f); + Assert.That(start.DeathWeight, Is.Zero); + Assert.That(start.HeatstrokeWeight, Is.EqualTo(1).Within(0.0001)); + Assert.That(middle.DeathWeight, Is.EqualTo(0.5).Within(0.0001)); + Assert.That(middle.HeatstrokeWeight, Is.EqualTo(0.5).Within(0.0001)); + + var retained = Projection(World(), Node("root", "surface", Empty(), + Node("death", "feedback.event", new Dictionary(StringComparer.Ordinal) + { + ["eventId"] = "death-1", ["eventKind"] = "entity.destroyed", + ["reason"] = "heatstroke", ["frameId"] = "20", ["currentFrameId"] = "25" + }))); + var reconnect = new EveUnityThermalPresentationState().Apply(World(), retained, 99f); + Assert.That(reconnect.DeathCause, Is.EqualTo("heatstroke")); + Assert.That(reconnect.DeathWeight, Is.EqualTo(1).Within(0.0001)); + Assert.That(reconnect.HeatstrokeWeight, Is.Zero); + } + + private static EveUnityPlayableWorldProjection World(params EveUnityPlayableWorldEntity[] entities) => + new EveUnityPlayableWorldProjection("world", "state", "assets", "input", "camera", "pilot", + "pilot", "move", "focus", "target", "action", entities); + + private static EveUnityPlayableWorldEntity Entity(IReadOnlyDictionary props) => + new EveUnityPlayableWorldEntity("node", "pilot", "ship", "Pilot", "player", "ship", 0, 0, 0, + 0, 1, true, true, "focus", "move", "target", "action", props); + + private static EveUnitySceneProjection Projection(EveUnityPlayableWorldProjection world, EveUnitySceneNode root) => + new EveUnitySceneProjection("provider", "surface", "scene", "commands", "receipts", "provider", + world, root); + + 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); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs.meta new file mode 100644 index 0000000..3d6d6d3 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityThermalPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b0147ca55ca4c2d9f811dddc889219a