Fire control Cut 4, part 1: FireControl gains Splash and a beam roll

FireControl.Fire takes an optional damageOverride so a continuous weapon can
queue a shot for less than its full Damage. FireControl.Splash is the one new
rule this cut adds: an unconditional area effect (mines, airburst) applying
the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic
over the directional half of each target's own hull that faces the blast --
the rule moved verbatim from the Splash subscription EntityInstance.cs
carried before Cut 3 deleted it (git show b7743789^).

ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval
(new setting) for Damage*interval, through the same FireControl.Fire/Step
pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight
commits and resolves in the same tick it fires (R4's short-flight
degradation). A beam is a sequence of rolls, not a continuous truth.

Fixes a real defect found while testing the above: both InstantWeapon.Execute
(Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl.
Fire(...))` inline. C#'s null-conditional short-circuits the whole
expression, argument included, whenever the event has no subscriber -- true
of any headless run with no Unity EntityInstance wired up
(docs/headless-playground-cut.md) -- so a shot silently never fired. Both
now evaluate FireControl.Fire into a local before the null-conditional
invoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-19 15:16:57 +02:00
commit 482c47bffe
4 changed files with 89 additions and 4 deletions

View file

@ -32,6 +32,9 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
private float _ammoInterval;
private float _reload;
private bool _reloading;
// Cut 4 (docs/fire-control-cut.md): accumulates while firing; rolled off every time it reaches
// GameplaySettings.BeamResolveInterval, through FireControl.Fire/Step exactly like a discrete shot.
private float _beamTimer;
public override int Ammo
{
@ -53,13 +56,18 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
public event Action OnReloadComplete;
public event Action OnStartFiring;
public event Action OnStopFiring;
// Cut 4 (docs/fire-control-cut.md): carries the ShotId each beam roll's FireControl.Fire assigned, the
// same handle InstantWeapon.OnFire gives discrete shots. No presentation binds to it yet -- kept for a
// future pass, same as Laser.ShotId.
public event Action<int> OnBeamShot;
public void ResetEvents()
{
OnReloadBegin = null;
OnReloadComplete = null;
OnStartFiring = null;
OnStopFiring = null;
OnBeamShot = null;
}
public ConstantWeapon(ConstantWeaponData data, EquippedItem item) : base(data, item)
@ -152,6 +160,23 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
CauseWearDamage(dt);
AddHeat(Evaluate(_data.Heat) * dt);
Entity.VisibilitySources[this] = Evaluate(_data.Visibility);
// Cut 4 (docs/fire-control-cut.md): a beam is a sequence of rolls, not a continuous truth -- one
// FireControl.Fire per BeamResolveInterval, for that interval's worth of Damage, through the same
// freeze-and-queue path a discrete shot uses (a beam's authored Velocity is 0, so the flight
// commits and resolves in this same tick, R4's short-flight degradation).
var interval = Entity.ItemManager.GameplaySettings.BeamResolveInterval;
_beamTimer += dt;
while (_beamTimer >= interval)
{
_beamTimer -= interval;
// FireControl.Fire must run whether or not anything is listening: `OnBeamShot?.Invoke(FireControl.Fire(...))`
// looks equivalent but is not -- C#'s null-conditional short-circuits the whole expression,
// argument included, when OnBeamShot has no subscriber (true today, R9's "no design uses it
// yet"), so the roll would silently never happen. Evaluate it into a local first.
var shotId = FireControl.Fire(this, Item, Entity, Damage * interval);
OnBeamShot?.Invoke(shotId);
}
}
return true;
}

View file

@ -240,7 +240,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// Cut 3: this is fire authority's one entry point. FireControl.Fire freezes the payload snapshot
// (Q6 -- the gun's own stats at this exact instant, base.Execute(dt) above already refreshed them
// this tick) and queues a PendingShot; nothing downstream re-evaluates a stat.
OnFire?.Invoke(FireControl.Fire(this, Item, Entity));
// Cut 4 fix: FireControl.Fire must run whether or not anything is listening -- inlined into
// `OnFire?.Invoke(...)` it looked equivalent but was not. C#'s null-conditional short-circuits the
// whole expression, argument included, whenever OnFire has no subscriber (a headless run with no
// Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired.
// Evaluate it into a local first.
var shotId = FireControl.Fire(this, Item, Entity);
OnFire?.Invoke(shotId);
if(!firedThisFrame)
{
Item.FireAudioEvent(WeaponAudioEvent.Fire);

View file

@ -155,7 +155,12 @@ public static class FireControl
// flight time, freezes the payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and
// queues a PendingShot for Zone.Step to age and eventually resolve. Returns the ShotId so the caller's
// OnFire event can carry it to presentation.
public static int Fire(Weapon weapon, EquippedItem item, Entity source)
// Cut 4 (docs/fire-control-cut.md): damageOverride lets a continuous weapon fire a shot for less than its
// full Damage -- ConstantWeapon.Execute rolls one of these per GameplaySettings.BeamResolveInterval, for
// Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's
// authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation,
// unchanged). Every other caller keeps reading the frozen weapon.Damage it always did.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null)
{
var zone = source.Zone;
var target = source.Target.Value;
@ -186,7 +191,7 @@ public static class FireControl
Target = target,
Weapon = item,
Aimed = source.ResolvedTargetItem,
Damage = weapon.Damage,
Damage = damageOverride ?? weapon.Damage,
Penetration = weapon.Penetration,
DamageSpread = weapon.DamageSpread,
DamageType = weapon.WeaponData.DamageType,
@ -337,6 +342,49 @@ public static class FireControl
};
}
// Cut 4 (docs/fire-control-cut.md): the one splash rule -- an unconditional area effect, not a rolled
// shot. Nothing here draws: a mine or an airburst round always damages everything it catches, the same
// as the Physics.OverlapSphere queries this replaces always did. Applies the shield-absorb-or-hull-takes-
// it branch (F5, docs/stats-and-power-cut.md) per target, then -- for a target the shield didn't fully
// absorb -- DamageSchematic over the directional half of that target's own hull that faces the blast, the
// rule moved verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it
// (git show b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target
// direction is measured in the zone's (x,z) plane and rotated into each target's own facing by its
// Direction, the same rotation Entity.ApplyHit's penetration march uses, in place of a Unity
// InverseTransformDirection.
public static void Splash(Zone zone, float3 position, float radius, float damage, DamageType damageType)
{
foreach (var target in zone.Entities)
{
var toTarget = (target.Position - position).xz;
if (length(toTarget) > radius) continue;
var shield = target.Shield;
var shieldActive = shield != null && shield.Item.Active.Value;
var shieldAbsorbs = shieldActive && shield.CanTakeHit(damageType, damage);
if (shieldActive && !shieldAbsorbs) shield.Break();
if (shieldAbsorbs)
{
shield.TakeHit(damageType, damage);
continue;
}
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
var forward = normalize(target.Direction);
var right = float2(forward.y, -forward.x);
var localDirection = lengthsq(toTarget) > 1e-6f
? normalize(float2(dot(toTarget, right), dot(toTarget, forward)))
: float2(0, 1);
var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height);
foreach (var v in hullData.Shape.Coordinates)
if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0)
hitShape[v] = true;
target.DamageSchematic(damage, hitShape);
}
}
// The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source
// of truth for where an equipped item's footprint lands, the same table Entity.DamageSchematic reads.
private static int2[] CellsOf(Entity target, EquippedItem item)

View file

@ -240,6 +240,12 @@ public class GameplaySettings
// a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone
// (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll.
public float AgentMinHitProbability = .2f;
// Cut 4 (docs/fire-control-cut.md): how often a continuous weapon (ConstantWeapon) rolls a discrete
// outcome for Damage * this interval, through the same FireControl.Fire/Step pair a discrete shot uses --
// a beam is a sequence of rolls, not a continuous truth. First guess; the headless fixture is the tuning
// harness, the operator smoke is the arbiter, same as Cut 3's other first guesses.
public float BeamResolveInterval = .25f;
}
[Serializable, MessagePackObject(keyAsPropertyName: true), JsonObject]