diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 760ebc069..61bf5821b 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -66,10 +66,9 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer private float _pingLerp; private bool _pinging; private float _pingRadius; - private float _previousRadius; - // Allied sensors mid-sweep this tick, gathered once per Execute (scratch, rebuilt every call). - private readonly List _alliedPingers = new List(); + // The subjects this ping's wavefront has reached, in the beam or not; each is crossed at most once per ping. + private readonly HashSet _reached = new HashSet(); public float Cooldown { @@ -81,22 +80,15 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer get => _pingRadius; } - // The wavefront radius before this tick's step: the ping crossed everything in [PreviousRadius, PingRadius). - public float PreviousRadius => _previousRadius; - public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth); public float2 Facing => Direction.xz; - // The one crossing rule: this tick's ping wavefront of `pinger` passed `subject`, inside the pinger's beam. - // It remembers nothing; the two radii are the whole state, so every observer of the same ping agrees. - private static bool Crossing(Sensor pinger, Entity subject) + // Whether the offset `diff` (length `dist`) from this sensor lies inside its beam. + private bool InBeam(float2 diff, float dist) { - var diff = subject.Position.xz - pinger.Entity.Position.xz; - var dist = length(diff); - if (dist < pinger._previousRadius || dist >= pinger._pingRadius) return false; - var beam = pinger.BeamWidth; - return beam >= 360f || dot(pinger.Facing, diff) >= dist * cos(beam * .5f * .0174533f); + var beam = BeamWidth; + return beam >= 360f || dot(Facing, diff) >= dist * cos(beam * .5f * .0174533f); } public float PingBrightness => pow(1 - _pingLerp, _data.PingRadiusExponent); @@ -145,6 +137,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown = 1; _pingLerp = 0; _pingRadius = 0; + _reached.Clear(); OnPingStart?.Invoke(); } } @@ -165,7 +158,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // instance never reaches PowerBus.Step (see Ping()) and so never accrues charge. if (Item != null) _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); - _previousRadius = _pingRadius; + var sweeping = _pinging; if (_pinging) { _pingLerp += dt / _data.PingDuration; @@ -180,13 +173,6 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown -= dt / Evaluate(_data.PingCooldown); var forward = Direction.xz; - _alliedPingers.Clear(); - foreach (var ally in Entity.Zone.Entities) - { - if (ally == Entity || ally.Faction == null || ally.Faction != Entity.Faction || Entity.IsHostileTo(ally)) continue; - foreach (var sensor in ally.GetBehaviors()) - if (sensor._previousRadius < sensor._pingRadius) _alliedPingers.Add(sensor); - } foreach (var entity in Entity.Zone.Entities) { if (entity == Entity) continue; @@ -197,31 +183,46 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer float previous, next; Entity.EntityInfoGathered.TryGetValue(entity, out previous); - // A crossing of this entity's own ping paints the subject (it learns it is swept); a crossing of an - // allied ping is heard silently (ruling sensor-stat-set item 3): credit, no paint, no emission. - var crossings = 0; - if (Crossing(this, entity)) - { - crossings++; - entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0); - } - foreach (var pinger in _alliedPingers) - if (pinger.Entity != entity && Crossing(pinger, entity)) crossings++; + var sensitivity = Evaluate(_data.Sensitivity); + var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost); + var credit = 0f; + var heard = false; - if (crossings > 0) + // The first sweeping tick this ping's wavefront is past the entity is its one crossing. Inside the beam + // it paints the subject (it learns it is swept), credits this sensor, and is returned to every ally; an + // ally hears it silently (ruling sensor-stat-set item 3): credit, no paint, no emission. + if (sweeping && dist < _pingRadius && _reached.Add(entity) && InBeam(diff, dist)) { - var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost); - next = saturate(previous + crossings * Gain(entity.Visibility, Evaluate(_data.Sensitivity), response, dist, dt, true, obscuration)); + heard = true; + entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0); + credit += Gain(entity.Visibility, sensitivity, response, dist, dt, true, obscuration); + var pingReturn = new PingReturn(entity, dist, obscuration, Evaluate(_data.PingRange)); + foreach (var ally in Entity.Zone.Entities) + if (ally != Entity && ally != entity && ally.Faction != null && ally.Faction == Entity.Faction && !ally.IsHostileTo(Entity)) + ally.ReceiveReturn(pingReturn); } + + // An ally's ping returned off this subject (ruling ally-echo-bistatic-oval): heard while the + // illuminated and received ranges sit inside the oval Rt x Rr < PingRange^2, credited at their geometric mean. + foreach (var r in Entity.PingReturns) + { + if (r.Subject != entity || r.IlluminatedRange * dist >= r.MaxRange * r.MaxRange) continue; + heard = true; + credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration)); + } + + if (heard) + next = saturate(previous + credit); else { var angle = acos(dot(forward, normalize(diff))); - next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration)); + next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration)); } next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt; //Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%"); Entity.EntityInfoGathered[entity] = next; } + if (!_pinging) _reached.Clear(); return true; } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 97caa40b7..b12eb6c7f 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -187,11 +187,16 @@ public abstract class Entity public ReactiveProperty TargetedByCount = new ReactiveProperty(0); // TargetedBy counts selection, which emits nothing; a warning comes only from a paint. A search paint is a ping // sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the - // inbox the tick it paints; PublishPaints publishes the inbox once, so a warning lives exactly one tick after + // inbox the tick it paints; PublishInbox publishes the inbox once, so a warning lives exactly one tick after // its last paint. private readonly List _paintInbox = new List(); private readonly List _incomingPaints = new List(); public IReadOnlyList IncomingPaints => _incomingPaints; + // An ally's sensor posts a return on the tick its ping crosses a subject; published with paints, read by this + // ship's own sensors for one tick. + private readonly List _returnInbox = new List(); + private readonly List _pingReturns = new List(); + internal IReadOnlyList PingReturns => _pingReturns; public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -804,16 +809,27 @@ public abstract class Entity VisibleFriendlies.Clear(); _paintInbox.Clear(); _incomingPaints.Clear(); + _returnInbox.Clear(); + _pingReturns.Clear(); } // Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published // at the start of tick N+1 whatever order the emitter and this ship update in; a paint that stops is gone // one tick later for every ordering. - internal void PublishPaints() + internal void PublishInbox() { _incomingPaints.Clear(); _incomingPaints.AddRange(_paintInbox); _paintInbox.Clear(); + _pingReturns.Clear(); + _pingReturns.AddRange(_returnInbox); + _returnInbox.Clear(); + } + + internal void ReceiveReturn(PingReturn pingReturn) + { + if (!_active) return; + _returnInbox.Add(pingReturn); } // The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 7cb47c4fb..bed29a429 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -40,6 +40,24 @@ public enum PaintMode Track } +// An ally's ping crossed `Subject` at `IlluminatedRange` through `IlluminatedObscuration`; `MaxRange` is that +// sensor's PingRange. Posted on the crossing tick and read for one tick by the receiving ship's own sensors. +public readonly struct PingReturn +{ + public readonly Entity Subject; + public readonly float IlluminatedRange; + public readonly float IlluminatedObscuration; + public readonly float MaxRange; + + public PingReturn(Entity subject, float illuminatedRange, float illuminatedObscuration, float maxRange) + { + Subject = subject; + IlluminatedRange = illuminatedRange; + IlluminatedObscuration = illuminatedObscuration; + MaxRange = maxRange; + } +} + public readonly struct PaintWarning { public readonly float2 Bearing; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index ebeee66b8..4aa2063cc 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -227,7 +227,7 @@ public class Zone agent.Update(deltaTime); var entities = Entities.ToArray(); - foreach (var entity in entities) entity.PublishPaints(); + foreach (var entity in entities) entity.PublishInbox(); foreach (var entity in entities) entity.Update(deltaTime); StepMines(deltaTime);