diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 5f7e3293d..1d8fe567f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -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 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; } diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index f87009974..6b9b14fc8 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 74fc8e358..b49c90105 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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) diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 1cfcb53a1..35c53693f 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -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]