2020-12-22 03:13:43 +00:00
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
2020-03-22 19:05:26 +00:00
|
|
|
|
|
2021-03-21 01:55:25 +00:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
2026-08-10 14:57:50 +01:00
|
|
|
|
using MessagePack;
|
|
|
|
|
|
using Newtonsoft.Json;
|
2026-09-14 17:39:12 +02:00
|
|
|
|
using CultMath;
|
|
|
|
|
|
using static CultMath.math;
|
2026-08-10 14:57:50 +01:00
|
|
|
|
|
|
|
|
|
|
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
|
|
|
|
|
|
public class SensorData : BehaviorData
|
|
|
|
|
|
{
|
|
|
|
|
|
[Inspectable, JsonProperty("sensitivity"), Key(3), RuntimeInspectable]
|
|
|
|
|
|
public PerformanceStat Sensitivity = new PerformanceStat();
|
|
|
|
|
|
|
|
|
|
|
|
[InspectableAnimationCurve, JsonProperty("sensCurve"), Key(4), RuntimeInspectable]
|
|
|
|
|
|
public BezierCurve SensitivityCurve;
|
|
|
|
|
|
|
|
|
|
|
|
[Inspectable, JsonProperty("pingBoost"), Key(5), RuntimeInspectable]
|
|
|
|
|
|
public PerformanceStat PingBoost;
|
|
|
|
|
|
|
|
|
|
|
|
[Inspectable, JsonProperty("pingEnergy"), Key(6), RuntimeInspectable]
|
|
|
|
|
|
public PerformanceStat PingEnergy;
|
|
|
|
|
|
|
2026-10-12 20:51:46 +02:00
|
|
|
|
// Key 7 (pingVisibility) is retired: never reuse it.
|
2026-08-10 14:57:50 +01:00
|
|
|
|
|
|
|
|
|
|
[Inspectable, JsonProperty("pingRange"), Key(8), RuntimeInspectable]
|
|
|
|
|
|
public PerformanceStat PingRange;
|
|
|
|
|
|
|
|
|
|
|
|
[Inspectable, JsonProperty("pingCooldown"), Key(9), RuntimeInspectable]
|
|
|
|
|
|
public PerformanceStat PingCooldown;
|
|
|
|
|
|
|
|
|
|
|
|
[Inspectable, JsonProperty("pingDuration"), Key(10)]
|
|
|
|
|
|
public float PingDuration = 2;
|
|
|
|
|
|
|
|
|
|
|
|
[Inspectable, JsonProperty("pingRadiusExponent"), Key(11)]
|
|
|
|
|
|
public float PingRadiusExponent = .5f;
|
2026-10-11 14:05:16 +02:00
|
|
|
|
|
|
|
|
|
|
// The width in degrees of the cone a ping sweeps, about the sensor's facing (ruling illuminator-beamforming:
|
|
|
|
|
|
// how much a ping reveals is a stat). 360 or more is omnidirectional; a design without it reads as 360.
|
|
|
|
|
|
[Inspectable, JsonProperty("beamWidth"), Key(12)]
|
|
|
|
|
|
public PerformanceStat BeamWidth;
|
2026-10-13 01:56:50 +02:00
|
|
|
|
|
|
|
|
|
|
// The ratio of how far this sensor sees to how far others hear it (ruling sensor-stat-set); a design without it
|
|
|
|
|
|
// reads as 1.
|
|
|
|
|
|
[Inspectable, JsonProperty("interceptFactor"), Key(13)]
|
|
|
|
|
|
public PerformanceStat InterceptFactor;
|
2026-08-10 14:57:50 +01:00
|
|
|
|
|
|
|
|
|
|
public override Behavior CreateInstance(EquippedItem item)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new Sensor(this, item);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public override Behavior CreateInstance(ConsumableItemEffect item)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new Sensor(this, item);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2020-03-22 19:05:26 +00:00
|
|
|
|
|
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
|
|
|
|
public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
2020-03-22 19:05:26 +00:00
|
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
|
private SensorData _data;
|
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
|
|
|
|
|
|
|
|
|
|
// Cut 4 (docs/stats-and-power-cut.md, Cut 4): replaces the direct Entity.TrySpendCapacitorCharge spend in
|
|
|
|
|
|
// Ping() below -- the named, temporary exception Cut 3 left standing.
|
|
|
|
|
|
private readonly InputCapacitor _capacitor = new InputCapacitor();
|
2021-03-21 01:55:25 +00:00
|
|
|
|
private float _pingCooldown;
|
|
|
|
|
|
private float _pingLerp;
|
|
|
|
|
|
private bool _pinging;
|
|
|
|
|
|
private float _pingRadius;
|
2026-10-11 14:05:16 +02:00
|
|
|
|
|
2026-10-11 15:22:29 +02:00
|
|
|
|
// The subjects this ping's wavefront has reached, in the beam or not; each is crossed at most once per ping.
|
2026-10-11 16:42:16 +02:00
|
|
|
|
// Ping() is the one reset. A sweeping tick drops every subject no longer in the zone, so a subject that
|
|
|
|
|
|
// leaves and comes back inside the front is reached again, as a ship arriving inside the front is.
|
2026-10-11 15:22:29 +02:00
|
|
|
|
private readonly HashSet<Entity> _reached = new HashSet<Entity>();
|
2020-03-22 19:05:26 +00:00
|
|
|
|
|
2026-10-11 16:42:16 +02:00
|
|
|
|
// The zone the ping began in: a ping belongs to it, and ends when the pinger is anywhere else.
|
|
|
|
|
|
private Zone _pingZone;
|
|
|
|
|
|
|
2021-03-21 01:55:25 +00:00
|
|
|
|
public float Cooldown
|
|
|
|
|
|
{
|
|
|
|
|
|
get => saturate(_pingCooldown);
|
|
|
|
|
|
}
|
2026-08-10 14:57:50 +01:00
|
|
|
|
|
2021-03-21 01:55:25 +00:00
|
|
|
|
public float PingRadius
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _pingRadius;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 14:05:16 +02:00
|
|
|
|
public float BeamWidth => _data.BeamWidth == null ? 360f : Evaluate(_data.BeamWidth);
|
|
|
|
|
|
|
2026-10-13 01:56:50 +02:00
|
|
|
|
public float InterceptFactor => _data.InterceptFactor == null ? 1f : Evaluate(_data.InterceptFactor);
|
|
|
|
|
|
|
2026-10-11 14:05:16 +02:00
|
|
|
|
public float2 Facing => Direction.xz;
|
|
|
|
|
|
|
2026-10-11 15:22:29 +02:00
|
|
|
|
// Whether the offset `diff` (length `dist`) from this sensor lies inside its beam.
|
|
|
|
|
|
private bool InBeam(float2 diff, float dist)
|
2026-10-11 14:05:16 +02:00
|
|
|
|
{
|
2026-10-11 15:22:29 +02:00
|
|
|
|
var beam = BeamWidth;
|
|
|
|
|
|
return beam >= 360f || dot(Facing, diff) >= dist * cos(beam * .5f * .0174533f);
|
2026-10-11 14:05:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 14:57:50 +01:00
|
|
|
|
public float PingBrightness => pow(1 - _pingLerp, _data.PingRadiusExponent);
|
2021-03-21 01:55:25 +00:00
|
|
|
|
|
|
|
|
|
|
public event Action OnPingStart;
|
|
|
|
|
|
public event Action OnPingEnd;
|
|
|
|
|
|
|
|
|
|
|
|
public void ResetEvents()
|
|
|
|
|
|
{
|
|
|
|
|
|
OnPingStart = null;
|
|
|
|
|
|
OnPingEnd = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
|
|
|
|
// Cut 4: capacity/rate, resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls
|
|
|
|
|
|
// PowerRequest below before this behaviour's own Execute runs this tick).
|
2026-09-19 06:58:57 +02:00
|
|
|
|
//
|
|
|
|
|
|
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PingEnergy and
|
|
|
|
|
|
// PingCooldown are registered request fields (StatValidation.PowerRequestFields) -- read nominally, same
|
|
|
|
|
|
// reasoning as InstantWeapon's own RefreshInputCapacitor.
|
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
|
|
|
|
private void RefreshInputCapacitor()
|
|
|
|
|
|
{
|
2026-09-19 06:58:57 +02:00
|
|
|
|
var energy = EvaluateNominalPower(_data.PingEnergy);
|
|
|
|
|
|
var cooldown = EvaluateNominalPower(_data.PingCooldown);
|
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
|
|
|
|
_capacitor.UpdateStats(energy, cooldown);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cut 4: the request PowerBus needs before Execute runs.
|
|
|
|
|
|
public float PowerRequest(float dt)
|
|
|
|
|
|
{
|
|
|
|
|
|
RefreshInputCapacitor();
|
|
|
|
|
|
return _capacitor.RequestedFill(dt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Cut 5: priority tiers replace PowerBus's single shared GrantRatio
PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their
per-behaviour-kind defaults: Radiator is Critical (the closest thing this game
has to life support -- a starved radiator cascades into wear and shutdown
across the whole hull), Shield is High, Thruster/AetherDrive are Medium,
ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility.
IPowerConsumer.DefaultPowerTier lets each behaviour own its own default.
EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the
unit rather than the design; EquippedItem's constructor seeds a kind default
the first time a unit is equipped, only when the field is still
PowerTiers.Unassigned, and never again -- a tier is a stored choice, not
something the bus re-derives.
PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in
full before the next tier sees anything left, an empty tier passes its whole
share down, and within a tier every consumer rations by the same
grant/demand fraction so unequal requests still divide proportionally. A new
TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to
read starvation from directly, instead of reconstructing it from every item's
PowerSupply.
Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its
only implementer): the bus already steps before any equipment's Execute, so
equip/behaviour order never decided who got fed even before this cut, and
nothing else needed the interface (subject to the map's O3 operator check).
docs/stats-and-power-cut.md Cut 5 / §1.3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
|
|
|
|
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else.
|
|
|
|
|
|
public int DefaultPowerTier => PowerTiers.Utility;
|
|
|
|
|
|
|
2021-03-21 01:55:25 +00:00
|
|
|
|
public void Ping()
|
|
|
|
|
|
{
|
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
|
|
|
|
// Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator
|
|
|
|
|
|
// ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so
|
|
|
|
|
|
// nothing would ever fill this capacitor; bypass it rather than starving such an instance forever.
|
|
|
|
|
|
if(_pingCooldown < 0 && (Item == null || _capacitor.TrySpend(_capacitor.Capacity)))
|
2021-03-21 01:55:25 +00:00
|
|
|
|
{
|
|
|
|
|
|
_pinging = true;
|
2026-10-11 16:42:16 +02:00
|
|
|
|
_pingZone = Entity.Zone;
|
2021-03-21 01:55:25 +00:00
|
|
|
|
_pingCooldown = 1;
|
|
|
|
|
|
_pingLerp = 0;
|
|
|
|
|
|
_pingRadius = 0;
|
2026-10-11 15:22:29 +02:00
|
|
|
|
_reached.Clear();
|
2021-03-21 01:55:25 +00:00
|
|
|
|
OnPingStart?.Invoke();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2020-03-22 19:05:26 +00:00
|
|
|
|
|
2026-08-10 14:57:50 +01:00
|
|
|
|
public Sensor(SensorData data, EquippedItem item) : base(data, item)
|
2021-04-18 05:13:20 +01:00
|
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
|
_data = data;
|
2021-04-18 05:13:20 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 14:57:50 +01:00
|
|
|
|
public Sensor(SensorData data, ConsumableItemEffect item) : base(data, item)
|
2020-03-22 19:05:26 +00:00
|
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
|
_data = data;
|
2020-03-22 19:05:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2021-04-18 05:13:20 +01:00
|
|
|
|
public override bool Execute(float dt)
|
2020-03-22 19:05:26 +00:00
|
|
|
|
{
|
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
|
|
|
|
// Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
|
|
|
|
|
|
// instance never reaches PowerBus.Step (see Ping()) and so never accrues charge.
|
|
|
|
|
|
if (Item != null)
|
|
|
|
|
|
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
|
2026-10-11 16:42:16 +02:00
|
|
|
|
if (_pinging && Entity.Zone != _pingZone) EndPing();
|
2026-10-11 15:22:29 +02:00
|
|
|
|
var sweeping = _pinging;
|
2021-03-21 01:55:25 +00:00
|
|
|
|
if (_pinging)
|
|
|
|
|
|
{
|
2026-08-10 14:57:50 +01:00
|
|
|
|
_pingLerp += dt / _data.PingDuration;
|
|
|
|
|
|
_pingRadius = lerp(0, Evaluate(_data.PingRange), pow(_pingLerp, _data.PingRadiusExponent));
|
2026-10-11 16:42:16 +02:00
|
|
|
|
_reached.IntersectWith(Entity.Zone.Entities);
|
2026-10-13 01:56:50 +02:00
|
|
|
|
Entity.Emit(Item, Evaluate(_data.PingRange) / InterceptFactor);
|
2026-10-11 16:42:16 +02:00
|
|
|
|
if (_pingLerp > 1) EndPing();
|
2021-03-21 01:55:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 14:57:50 +01:00
|
|
|
|
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
|
|
|
|
|
|
|
2021-04-18 05:13:20 +01:00
|
|
|
|
var forward = Direction.xz;
|
|
|
|
|
|
foreach (var entity in Entity.Zone.Entities)
|
2021-02-26 19:01:47 +00:00
|
|
|
|
{
|
2021-04-18 05:13:20 +01:00
|
|
|
|
if (entity == Entity) continue;
|
2026-09-30 12:56:13 +02:00
|
|
|
|
|
2026-08-10 14:57:50 +01:00
|
|
|
|
var diff = entity.Position.xz - Entity.Position.xz;
|
2021-04-12 04:20:52 +01:00
|
|
|
|
var dist = length(diff);
|
2026-10-09 20:22:16 +02:00
|
|
|
|
var obscuration = Entity.Zone.Obscuration(Entity.Position.xz, entity.Position.xz);
|
2021-04-12 04:20:52 +01:00
|
|
|
|
float previous, next;
|
2021-04-18 05:13:20 +01:00
|
|
|
|
Entity.EntityInfoGathered.TryGetValue(entity, out previous);
|
2026-10-11 14:05:16 +02:00
|
|
|
|
|
2026-10-11 15:22:29 +02:00
|
|
|
|
var sensitivity = Evaluate(_data.Sensitivity);
|
|
|
|
|
|
var response = _data.PingBoost == null ? 0f : Evaluate(_data.PingBoost);
|
|
|
|
|
|
var credit = 0f;
|
|
|
|
|
|
var heard = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 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))
|
2026-10-11 14:05:16 +02:00
|
|
|
|
{
|
2026-10-11 15:22:29 +02:00
|
|
|
|
heard = true;
|
2026-10-11 18:25:57 +02:00
|
|
|
|
entity.ReceivePaint(Entity, Item, PaintMode.Search, 0);
|
2026-10-11 15:22:29 +02:00
|
|
|
|
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);
|
2026-10-11 14:05:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 15:22:29 +02:00
|
|
|
|
// 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)
|
2021-02-26 19:01:47 +00:00
|
|
|
|
{
|
2026-10-11 15:22:29 +02:00
|
|
|
|
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));
|
2021-02-26 19:01:47 +00:00
|
|
|
|
}
|
2026-10-11 15:22:29 +02:00
|
|
|
|
|
|
|
|
|
|
if (heard)
|
|
|
|
|
|
next = saturate(previous + credit);
|
2021-04-12 04:20:52 +01:00
|
|
|
|
else
|
|
|
|
|
|
{
|
2026-09-30 12:56:13 +02:00
|
|
|
|
var angle = acos(dot(forward, normalize(diff)));
|
2026-10-11 15:22:29 +02:00
|
|
|
|
next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
|
2021-04-12 04:20:52 +01:00
|
|
|
|
}
|
2021-04-18 05:13:20 +01:00
|
|
|
|
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
|
2021-04-12 04:20:52 +01:00
|
|
|
|
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
|
2021-04-18 05:13:20 +01:00
|
|
|
|
Entity.EntityInfoGathered[entity] = next;
|
2021-02-11 03:14:20 +00:00
|
|
|
|
}
|
2020-05-11 01:17:10 +01:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-10-11 16:42:16 +02:00
|
|
|
|
|
|
|
|
|
|
private void EndPing()
|
|
|
|
|
|
{
|
|
|
|
|
|
_pinging = false;
|
|
|
|
|
|
OnPingEnd?.Invoke();
|
|
|
|
|
|
}
|
2026-09-30 12:56:13 +02:00
|
|
|
|
|
|
|
|
|
|
// Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a
|
|
|
|
|
|
// target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds
|
|
|
|
|
|
// `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the
|
2026-10-09 20:22:16 +02:00
|
|
|
|
// sensitivity curve at the bearing for a passive tick. `obscuration` is the fraction of the signal that
|
|
|
|
|
|
// survives the vapour clouds on the sight line (Zone.Obscuration, 1f with none), scaling both. The entity
|
|
|
|
|
|
// loop above and the chunk query (Entity.ChunkInfo, through PassiveRate) both call this; nothing else
|
|
|
|
|
|
// multiplies these terms.
|
|
|
|
|
|
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping, float obscuration) =>
|
|
|
|
|
|
(ping
|
2026-09-30 12:56:13 +02:00
|
|
|
|
? visibility * sensitivity * response * distance
|
2026-10-09 20:22:16 +02:00
|
|
|
|
: visibility * sensitivity * response * dt / distance) * obscuration;
|
2026-09-30 12:56:13 +02:00
|
|
|
|
|
|
|
|
|
|
// The passive gain per second this sensor draws from a target of `visibility` at the planar `position`:
|
|
|
|
|
|
// Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does.
|
|
|
|
|
|
public float PassiveRate(float visibility, float2 position)
|
|
|
|
|
|
{
|
|
|
|
|
|
var diff = position - Entity.Position.xz;
|
|
|
|
|
|
var angle = acos(dot(Direction.xz, normalize(diff)));
|
2026-10-09 20:22:16 +02:00
|
|
|
|
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false,
|
|
|
|
|
|
Entity.Zone.Obscuration(Entity.Position.xz, position));
|
2026-09-30 12:56:13 +02:00
|
|
|
|
}
|
2026-10-03 15:02:51 +02:00
|
|
|
|
|
|
|
|
|
|
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance
|
|
|
|
|
|
// (Entity.DetectionReachPerVisibility). The curve lies within its control points' hull.
|
|
|
|
|
|
public float PassiveGainBound()
|
|
|
|
|
|
{
|
|
|
|
|
|
var sensitivity = Evaluate(_data.Sensitivity);
|
|
|
|
|
|
var response = _data.SensitivityCurve.HullRange();
|
|
|
|
|
|
return max(sensitivity * response.x, sensitivity * response.y);
|
|
|
|
|
|
}
|
2026-08-10 14:57:50 +01:00
|
|
|
|
}
|