Vapour clouds: Zone owns them, a VapourDump behaviour vents one (union 42)

Zone.Vent is the one adder, StepClouds the one remover, Zone.Obscuration the
one reader: the product of (1 - opacity) over clouds whose disc the segment
touches (closest point of the segment to the centre within Radius), 1f when
none. Derived from live clouds at each call, never cached. VapourDump vents
once on its first run, drifting with its venter. Nothing reads Obscuration
yet (Sensor.Gain takes it in the next commit); this commit builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-09 20:22:00 +02:00
commit 6dcf33d11c
7 changed files with 426 additions and 0 deletions

View file

@ -187,6 +187,7 @@ public interface IPowerConsumer
// superseded draft quoted -- checked directly here rather than trusted from that document.
Union(39, typeof(TargetingSystemData)),
Union(40, typeof(MineLayerData)),
Union(42, typeof(VapourDumpData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{

View file

@ -0,0 +1,61 @@
using MessagePack;
using Newtonsoft.Json;
// A coolant dump that becomes a vapour cloud: the first time it runs it vents one cloud around its venter,
// drifting with the venter's velocity. Radius, Opacity and Lifetime are evaluated through the host, so role
// quality applies as for any other stat. Later runs vent nothing.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class VapourDumpData : BehaviorData
{
[Inspectable, JsonProperty("radius"), Key(1), RuntimeInspectable]
public PerformanceStat Radius = new PerformanceStat();
// How much of a sight line's signal a fresh cloud removes, in [0, 1].
[Inspectable, JsonProperty("opacity"), Key(2), RuntimeInspectable]
public PerformanceStat Opacity = new PerformanceStat();
// Seconds from venting until the cloud has faded away and is removed.
[Inspectable, JsonProperty("lifetime"), Key(3), RuntimeInspectable]
public PerformanceStat Lifetime = new PerformanceStat();
public override Behavior CreateInstance(EquippedItem item)
{
return new VapourDump(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new VapourDump(this, item);
}
}
public class VapourDump : Behavior
{
private VapourDumpData _data;
private bool _vented;
public VapourDump(VapourDumpData data, EquippedItem item) : base(data, item)
{
_data = data;
}
public VapourDump(VapourDumpData data, ConsumableItemEffect item) : base(data, item)
{
_data = data;
}
public override bool Execute(float dt)
{
if (_vented) return true;
_vented = true;
Entity.Zone.Vent(new VapourCloud
{
Body = new KinematicBody { Position = Entity.Position.xz, Velocity = Entity.Velocity },
Radius = Evaluate(_data.Radius),
Opacity = Evaluate(_data.Opacity),
Lifetime = Evaluate(_data.Lifetime),
Venter = Entity
});
return true;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 961a74683c574807a300eb26694f206d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,31 @@
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// A vented vapour cloud: a free-floating disc that thins every sensor sight line through it. Not an Entity,
// never priced or rolled, never saved. Zone.Vent is the only adder and Zone's cloud step the only remover;
// Zone.Obscuration is the one reader detection uses.
public sealed class VapourCloud
{
public FloatingBodyId Id;
public KinematicBody Body;
public double VentedAt;
public float Lifetime;
public float Radius;
// The opacity at the moment of venting; it fades linearly to nothing over Lifetime.
public float Opacity;
public Entity Venter;
public float OpacityAt(double now) =>
Lifetime <= 0f ? 0f : saturate(Opacity) * saturate(1f - (float) (now - VentedAt) / Lifetime);
// Whether the segment a-b reaches the disc: the closest point of the segment to the centre lies within the
// radius. A cloud past either end of the segment touches it only if it is near that end.
public bool Touches(float2 a, float2 b)
{
var ab = b - a;
var length2 = lengthsq(ab);
var t = length2 > 0f ? saturate(dot(Body.Position - a, ab) / length2) : 0f;
return lengthsq(Body.Position - (a + ab * t)) <= Radius * Radius;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ced45a9738974452b0065b57ee527e8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -20,6 +20,10 @@ public class Zone
// Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover,
// and every removal is a blast. Runtime state, never saved.
public ReactiveCollection<Mine> Mines = new ReactiveCollection<Mine>();
// Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one
// remover, and Obscuration the one reader that detection uses. Runtime state, never saved.
public ReactiveCollection<VapourCloud> Clouds = new ReactiveCollection<VapourCloud>();
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
@ -225,6 +229,7 @@ public class Zone
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
StepMines(deltaTime);
StepClouds(deltaTime);
// Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that
// firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and
@ -238,6 +243,39 @@ public class Zone
Mines.Add(mine);
}
public void Vent(VapourCloud cloud)
{
cloud.Id = NextBodyId();
cloud.VentedAt = _time;
Clouds.Add(cloud);
}
// The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of
// (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the
// live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection.
public float Obscuration(float2 a, float2 b)
{
var through = 1f;
for (var i = 0; i < Clouds.Count; i++)
{
var cloud = Clouds[i];
if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time);
}
return through;
}
// After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed
// once its lifetime has run out. Nothing else removes a cloud.
private void StepClouds(float deltaTime)
{
var settings = _itemManager.GameplaySettings;
foreach (var cloud in Clouds.ToArray())
{
cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings);
if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud);
}
}
// After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is
// triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at
// its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The