Merge origin/master (da6fe033) into faction-play-1

Combat.cs: master's lazy optimum-range owner kept, the OptimumRange accessor beside it. FireControlPerWeaponTests: the exponent test runs the first Update before reading the optimum (master derives it there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 22:01:56 +02:00
commit 2ff48d0b45
10 changed files with 800 additions and 12 deletions

View file

@ -10,22 +10,42 @@ using float2x2 = CultMath.float2x2;
public class CombatState : BaseState
{
private const int DPS_SAMPLE_COUNT = 32;
// The Strength of one Track paint in this ship's own IncomingPaints: a fire-control emitter tracking this ship
// that hard (rulings lock-warning-strength, sensor-stat-set item 4, launcher-lock-solution-quality).
private const float LockAlarm = .5f;
// Overdrive only to close a target farther than this many optimum ranges, while heading within this many degrees of it.
private const float OverdriveRangeFactor = 2f;
private const float OverdriveArcDegrees = 15f;
private float _optimumRange;
public float OptimumRange => _optimumRange;
private bool _optimumStale = true;
private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>();
private readonly List<LockWeapon> _availableLockingWeapons = new List<LockWeapon>();
public CombatState(Agent agent) : base(agent)
{
SampleDps();
foreach (var weapon in _agent.Ship.Weapons)
{
weapon.Item.Online.Subscribe(b => SampleDps());
}
foreach (var item in _agent.Ship.Equipment) Watch(item);
_agent.Ship.Equipment.ObserveAdd().Subscribe(add => Watch(add.Value));
_agent.Ship.Equipment.ObserveRemove().Subscribe(_ => _optimumStale = true);
}
// The optimum is a function of the loadout's evaluated stats, so it is re-derived at the next Update (agents update
// before the entities, so by then the weapons that came online this tick have evaluated) whenever a weapon is
// added, removed or changes online state. Sampling on the event itself would read the stats before they exist: a
// ship the zone just spawned would keep an optimum of 0.
private void Watch(EquippedItem item)
{
if (item.Behaviors.OfType<Weapon>().Any()) item.Online.Subscribe(_ => _optimumStale = true);
}
public override void Update(float delta)
{
if (_optimumStale)
{
SampleDps();
_optimumStale = false;
}
var target = _agent.Ship.Target.Value.Entity;
if (target == null) return;
@ -79,6 +99,8 @@ public class CombatState : BaseState
foreach(var w in _availableLockingWeapons) w.Activate();
UseConsumables(toTarget, targetDistance);
var selectedGroup = -1;
var maxDps = Single.MinValue;
foreach (var group in _availableGroups)
@ -133,6 +155,27 @@ public class CombatState : BaseState
// Fire charged guns!
}
// The AI plays the player's rule on the player's information: vent when one Track paint on this ship passes half,
// otherwise overdrive to close a distant target ahead. It reads only this ship's own IncomingPaints. Both go
// through TryActivateConsumable, and a ship carrying neither does nothing.
private void UseConsumables(float3 toTarget, float targetDistance)
{
var ship = _agent.Ship;
if (ship.IncomingPaints.Any(p => p.Mode == PaintMode.Track && p.Strength > LockAlarm))
Use(b => b is VapourDumpData);
else if (targetDistance > OverdriveRangeFactor * _optimumRange &&
dot(normalize(ship.Direction), normalize(toTarget.xz)) > cos(radians(OverdriveArcDegrees)))
Use(b => b is ThrottleLockData);
}
// One of a kind at a time: a Stackable design would otherwise be drained once per tick.
private void Use(Predicate<BehaviorData> kind)
{
if (_agent.Ship.HasActiveConsumable(kind)) return;
var consumable = _agent.Ship.FirstCarriedConsumable(kind);
if (consumable != null) _agent.Ship.TryActivateConsumable(consumable);
}
private void SampleDps()
{
var minRange = Single.MaxValue;

View file

@ -18,6 +18,29 @@ public static class ColorMath
return saturate(float3(r,g,b));
}
// A hue band on the circle runs from start up to end, wrapping past 1 when end < start.
public static bool HueInBand(float hue, float start, float end) => frac(hue - start) < frac(end - start);
// Maps u in [0,1) onto the hues outside the band, so a uniform draw stays uniform over the allowed arc.
public static float HueOutsideBand(float u, float start, float end) => frac(end + u * AllowedArc(start, end));
static float AllowedArc(float start, float end) => 1 - frac(end - start);
// The second hue of a pair whose first is HueOutsideBand(u): distance away along the allowed arc, on the side given by up,
// reflected to the other side if that would leave the arc. Both hues lie on one arc, so the straight RGB blend between
// the two stops never crosses the band. The distance is capped at half the arc, so the reflection always fits and a
// wide band shortens the distance instead of leaving the secondary inside it.
public static float SecondaryHueOutsideBand(float u, bool up, float distance, float start, float end)
{
float arc = AllowedArc(start, end);
float d = min(distance, arc / 2);
float t = u * arc;
float s = up ? t + d : t - d;
if (s < 0 || s > arc)
s = up ? t - d : t + d;
return frac(end + s);
}
public static float3 HsvToRgb(in float3 hsv)
{
float3 rgb = HueToRgb(hsv.x);
@ -35,7 +58,7 @@ public static class ColorMath
public static float3 RgbToHcv(in float3 rgb)
{
// Based on work by Sam Hocevar and Emil Persson
float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.zy, 0.0f, -1.0f/3.0f);
float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.yz, 0.0f, -1.0f/3.0f);
float4 q = (rgb.x < p.x) ? float4(p.xyw, rgb.x) : float4(rgb.x, p.yzx);
float c = q.x - min(q.w, q.y);
float h = abs((q.w - q.y) / (6 * c + Epsilon) + q.z);

View file

@ -1353,8 +1353,20 @@ public abstract class Entity
return data.Stackable || FindActiveConsumable(data) == null;
}
// The first consumable design in cargo with a behaviour of the asked kind, or null. Callers ask by kind, never
// by name: a design's name is a label and its behaviours are what it does.
public ConsumableItemData FirstCarriedConsumable(Predicate<BehaviorData> kind) =>
CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType<ConsumableItem>()
.Select(item => ItemManager.GetData(item) as ConsumableItemData)
.FirstOrDefault(data => data != null && data.Behaviors.Any(b => kind(b)));
public bool HasActiveConsumable(Predicate<BehaviorData> kind) =>
_activeConsumables.Any(e => e.Data.Behaviors.Any(b => kind(b)));
public bool TryActivateConsumable(ConsumableItemData data)
{
// Death and docking Remove and Deactivate the ship while its agent keeps ticking.
if (!_active) return false;
if (!CanActivateConsumable(data)) return false;
var key = ItemManager.ItemData.RefOf<ItemData>(data).Key;

View file

@ -142,6 +142,9 @@ public class ZoneGenerationSettings
public float SunSecondaryColorDistance = .25f;
public float SunLightSaturation = .5f;
public float SunFogTintSaturation = .5f;
// The green band no sun colour may take, as fractions of the hue circle (75 to 165 degrees: chartreuse through spring green).
public float SunHueExcludedStart = 75f / 360f;
public float SunHueExcludedEnd = 165f / 360f;
public ExponentialLerp GasGiantBandCount;
public float GasGiantBandColorSeparation = .25f;

View file

@ -159,8 +159,10 @@ public static class ZoneGenerator
{
if (gas is SunData sun)
{
float primary = random.NextFloat();
float secondary = frac(primary + 1 + zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1));
var hueDraw = random.NextFloat();
float primary = ColorMath.HueOutsideBand(hueDraw, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd);
float secondary = ColorMath.SecondaryHueOutsideBand(hueDraw, random.NextFloat() > .5, zoneSettings.SunSecondaryColorDistance,
zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd);
gas.Colors = new []
{
float4(ColorMath.HsvToRgb(float3(primary, zoneSettings.SunColorSaturation, .5f)), 0),