Cut 12.4(b): Detonate replaces Splash and Entity.Absorb
FireControl.Apply now switches on the frozen shot.Fuse: a null fuse keeps 12.3's lane path (ApplyDirectHit), a proximity fuse detonates at arrival with no roll, and a contact or delayed fuse detonates only on a committed hit (ApplyBlastHit), locating P through Lane and the new Reach owner (moved verbatim from Apply's own inline penetration clip; the delayed fuse point reads the identical rule). Step has no damage branch left. Detonate (FireControl.cs) is the one owner of blast damage: candidates by centre-plus-half-diagonal, exact circle/cell overlap shares computed once, every blast's shield decided live per Q12-9 = A (including the host of a contact/delayed blast -- Commit now gates its own shield decision on shot.Fuse == null), armour absorbed per cell, and each item's covered cells pooled into one ItemAbsorb call. Entity.Absorb and FireControl.Splash are deleted; Entity gains ToSchematicPoint/ToWorldPoint for the point conversion Detonate and the delayed fuse point need. Mine.Explode now calls Detonate. Existing Splash/Absorb-era tests across Cut4/5/6/11/123 are rewritten or retargeted to the new public owners; behavioural coverage for the blast model itself lands separately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
77be5cf433
commit
7c99909d2b
12 changed files with 441 additions and 251 deletions
|
|
@ -461,11 +461,11 @@ public static class FireControl
|
|||
|
||||
if (shot.Committed && now >= shot.ArrivalTime)
|
||||
{
|
||||
// Cut 6b, 6.2 (renamed 12.4(a)): a shot with a frozen BlastRadius > 0 resolves as an area effect
|
||||
// instead of a discrete hit -- Splash instead of Apply, never both, which is exactly the
|
||||
// double-application Soul was told to hunt for. The fuse switch itself is 12.4(b)'s.
|
||||
if (shot.BlastRadius > 0f) Splash(zone, shot.BurstPosition, shot.BlastRadius, shot.Damage, shot.DamageType);
|
||||
else Apply(shot);
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
|
||||
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
|
||||
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
|
||||
// the same way; only Apply decides what that means.
|
||||
Apply(shot);
|
||||
zone.ShotResolved.OnNext(shot.Outcome);
|
||||
shots.RemoveAt(i);
|
||||
}
|
||||
|
|
@ -579,10 +579,17 @@ public static class FireControl
|
|||
ArrayPool<LaneCell>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
var shield = shot.Target.Shield;
|
||||
var shieldActive = shield != null && shield.Item.Active.Value;
|
||||
if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true;
|
||||
else if (shieldActive) shieldBroken = true;
|
||||
// Q12-9 = A (docs/fire-control-cut.md): Commit decides no shield for a shot that carries a fuse --
|
||||
// Detonate decides every blast's shields live, at detonation, the host included, charged the
|
||||
// entity's own covered share. A direct hit (Fuse == null) is unchanged: Commit still decides it here,
|
||||
// on the whole frozen Damage.
|
||||
if (shot.Fuse == null)
|
||||
{
|
||||
var shield = shot.Target.Shield;
|
||||
var shieldActive = shield != null && shield.Item.Active.Value;
|
||||
if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true;
|
||||
else if (shieldActive) shieldBroken = true;
|
||||
}
|
||||
}
|
||||
|
||||
return MakeOutcome(shot, hit, shielded, shieldBroken, cell, bearing, lateral, now);
|
||||
|
|
@ -632,7 +639,45 @@ public static class FireControl
|
|||
// membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There
|
||||
// is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a
|
||||
// pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork.
|
||||
//
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of which model
|
||||
// a shot uses, through the switch on the frozen shot.Fuse below. Step has no damage branch left -- every
|
||||
// arrived shot, direct hit or blast, comes through here. A null fuse keeps 12.3's lane path unchanged; a
|
||||
// proximity fuse detonates at arrival with no roll, whether the shot hit or missed
|
||||
// (ProximityDetonatesEvenOnAGuaranteedMiss keeps today's AirburstSplashesEvenOnAGuaranteedMiss rule); a
|
||||
// contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery
|
||||
// locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the
|
||||
// centre lane (see the fuse-point comment on the Contact/Delayed branch below).
|
||||
private static void Apply(PendingShot shot)
|
||||
{
|
||||
switch (shot.Fuse)
|
||||
{
|
||||
case null:
|
||||
ApplyDirectHit(shot);
|
||||
return;
|
||||
case WeaponFuse.Proximity:
|
||||
// Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst
|
||||
// rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit.
|
||||
Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType);
|
||||
return;
|
||||
case WeaponFuse.Contact:
|
||||
case WeaponFuse.Delayed:
|
||||
ApplyBlastHit(shot);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step
|
||||
// march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A):
|
||||
// 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the
|
||||
// centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's
|
||||
// own contiguous cell run from its own facing cell, clipped to the penetration depth by Reach (the impact
|
||||
// cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a lane does not
|
||||
// spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane ends (Q12-3 =
|
||||
// A), summed across every lane into one DamageHull call.
|
||||
// Cut 12.4(b): unchanged from 12.3 except that the inline penetration clip is now Reach, the shared owner
|
||||
// the delayed fuse point also reads (see "The penetrator").
|
||||
private static void ApplyDirectHit(PendingShot shot)
|
||||
{
|
||||
if (!shot.Outcome.Hit) return;
|
||||
|
||||
|
|
@ -648,7 +693,8 @@ public static class FireControl
|
|||
|
||||
var target = shot.Target;
|
||||
// Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return
|
||||
// before this runs, unchanged from 12.2.
|
||||
// before this runs, unchanged from 12.2. Cut 12.4(b): fires only for a direct hit; a proximity burst
|
||||
// and every bystander in any blast never fire it (AContactBlastReportsTheHit covers the blast half).
|
||||
target.IncomingHit.OnNext(shot.Source);
|
||||
|
||||
// Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own --
|
||||
|
|
@ -667,19 +713,10 @@ public static class FireControl
|
|||
buffers[li] = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
|
||||
var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers);
|
||||
|
||||
// Cut 12.3 fix batch ("one code path"): the ONLY penetration derivation in this whole walk -- clip each
|
||||
// lane's own cell run to its penetration depth right here, once, immediately after Lanes returns.
|
||||
// Everything downstream reads `walked` as the lane's entire reach; nothing below ever re-tests a cell's
|
||||
// entry against penetration again, and there is no separate survey of which cells or items a lane can
|
||||
// reach -- ApplyPooled's own walk discovers that live, from `walked` alone.
|
||||
// Cut 12.4(b) ("The penetrator", Q12-8): Reach is now the one penetration-reach owner, shared with the
|
||||
// delayed fuse point below -- no second reach derivation exists anywhere in this file.
|
||||
for (var li = 0; li < laneCount; li++)
|
||||
{
|
||||
if (walked[li] == 0) continue;
|
||||
var impactEntry = buffers[li][0].Entry;
|
||||
var clipped = 1;
|
||||
while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < shot.Penetration) clipped++;
|
||||
walked[li] = clipped;
|
||||
}
|
||||
walked[li] = Reach(buffers[li], walked[li], shot.Penetration);
|
||||
|
||||
var metalLanes = 0;
|
||||
for (var li = 0; li < laneCount; li++)
|
||||
|
|
@ -696,6 +733,66 @@ public static class FireControl
|
|||
target.DamageHull(hullDamage);
|
||||
}
|
||||
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit --
|
||||
// on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host,
|
||||
// before the blast is handed to Detonate (which then treats the host like any other entity in the radius:
|
||||
// Detonate never reads a host parameter, and finds the host's own cells again by ToSchematicPoint).
|
||||
private static void ApplyBlastHit(PendingShot shot)
|
||||
{
|
||||
if (!shot.Outcome.Hit) return;
|
||||
|
||||
var target = shot.Target;
|
||||
target.IncomingHit.OnNext(shot.Source);
|
||||
|
||||
// "Where P is": the lane's own parametrisation gives the point directly, point(t) = t*b + s*ell, on the
|
||||
// committed lane (the committed Bearing and Lateral, which Commit's own guard already proved non-empty,
|
||||
// R3). Contact: t is the impact cell's own entry, lane[0].Entry. Delayed: t_fuse is where the frozen
|
||||
// Penetration runs out or the metal does, whichever comes first -- Reach (the same owner the direct-hit
|
||||
// walk reads) decides how deep the lane reaches, and t_fuse clamps to the last reached cell's exit so
|
||||
// the disc is always centred on metal.
|
||||
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
|
||||
var bearing = shot.Outcome.Bearing;
|
||||
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
|
||||
float2 schematicPoint;
|
||||
try
|
||||
{
|
||||
var walked = Lane(hullData, bearing, shot.Outcome.Lateral, buffer);
|
||||
var t = buffer[0].Entry;
|
||||
if (shot.Fuse == WeaponFuse.Delayed)
|
||||
{
|
||||
var reach = Reach(buffer, walked, shot.Penetration);
|
||||
t = min(buffer[0].Entry + shot.Penetration, buffer[reach - 1].Exit);
|
||||
}
|
||||
schematicPoint = t * bearing + shot.Outcome.Lateral * Lateral(bearing);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<LaneCell>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
// "Conversion to the world": the round trip through the host's own ToWorldPoint/ToSchematicPoint
|
||||
// cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while
|
||||
// every bystander is judged against where it actually is at arrival -- the same live rule a proximity
|
||||
// burst already applies to everyone.
|
||||
var worldPoint = target.ToWorldPoint(schematicPoint);
|
||||
Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType);
|
||||
}
|
||||
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved
|
||||
// verbatim from Apply's own inline clip (Cut 12.3 fix batch) so the delayed fuse point above can read the
|
||||
// identical rule the direct-hit walk already used. The rule is unchanged: the impact cell is always
|
||||
// reached, and a later cell is reached while entry - impact.entry < penetration. Two callers, and no third:
|
||||
// a null-fuse shot spends damage along the walk (ApplyDirectHit), a contact-or-delayed shot only locates P
|
||||
// (ApplyBlastHit) -- no shot ever takes both branches of Apply's switch.
|
||||
private static int Reach(LaneCell[] cells, int walked, float penetration)
|
||||
{
|
||||
if (walked == 0) return 0;
|
||||
var impactEntry = cells[0].Entry;
|
||||
var reach = 1;
|
||||
while (reach < walked && cells[reach].Entry - impactEntry < penetration) reach++;
|
||||
return reach;
|
||||
}
|
||||
|
||||
// Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" --
|
||||
// docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far.
|
||||
// Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the
|
||||
|
|
@ -947,56 +1044,201 @@ 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 -- an even split (still today's rule; 12.3 only retired the direct-hit lane's even split, not
|
||||
// Splash's) 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's own schematic frame (ToSchematic) uses.
|
||||
// Cut 12.3: Entity.DamageSchematic is deleted with the direct-hit march it shared a file with -- Splash
|
||||
// now calls Entity.Absorb per footprint cell directly and sums the leftover into one DamageHull call,
|
||||
// arithmetically identical to DamageSchematic's own body (same per-cell split, same order, same
|
||||
// thresholds). Untouched otherwise; deleted in 12.4 along with the rest of the ray model.
|
||||
public static void Splash(Zone zone, float3 position, float radius, float damage, DamageType damageType)
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the one owner of blast damage -- a blast is
|
||||
// an area, not a bundle of rays (Q12-7 dissolved the ray model's remainder question along with the rays
|
||||
// themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches and
|
||||
// Mine.Explode. Nothing draws: every candidate entity in radius takes its share, unconditionally.
|
||||
//
|
||||
// For each candidate (an entity whose centre lies within radius plus half its own hull diagonal --
|
||||
// CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius, which Splash's centre-distance cull could not pass):
|
||||
// take Ps = ToSchematicPoint(P) and rCells = radius / SchematicCellSize once, then for every occupied hull
|
||||
// cell whose unit square meets the disc, share_c = damage * overlap(c) / (pi * rCells^2) -- the exact area
|
||||
// of the disc inside that cell's unit square, over the disc's own area. Every share that falls on an
|
||||
// unoccupied cell, or outside the schematic, is lost (TheShareOffTheHullIsLost); nothing renormalises over
|
||||
// the covered cells.
|
||||
//
|
||||
// Every shield decision for a blast is made here, live, charged the entity's summed shares under today's
|
||||
// CanTakeHit/Break/TakeHit rules (Q12-9 = A: the host of a contact or delayed blast decides its shield here
|
||||
// too, never at Commit). If the shield absorbs, no cell of that entity takes anything; if it cannot, it
|
||||
// breaks and the cells take their shares.
|
||||
//
|
||||
// Armour protects only its own cell's share (ArmorProtectsOnlyItsOwnCellsShare) -- "armour absorbs first"
|
||||
// runs per cell, in hull Coordinates order. Only once every covered cell has deposited does an item resolve
|
||||
// its own pool, once (AMultiCellItemAbsorbsItsCoveredCellsAsOnePool): no per-cell item threshold survives
|
||||
// anywhere, because a disc's schedule has no walk -- no cell's share depends on another
|
||||
// cell's leftover, so every deposit is known before any item resolves and there is nothing for a
|
||||
// reachability test or a cycle rule to decide (unlike ApplyPooled's lane walk, which this function does not
|
||||
// call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares,
|
||||
// and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one
|
||||
// DamageHull call.
|
||||
public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType)
|
||||
{
|
||||
foreach (var target in zone.Entities)
|
||||
{
|
||||
var toTarget = (target.Position - position).xz;
|
||||
if (length(toTarget) > radius) continue;
|
||||
// F3 (Soul, Cut 12.3 fold-in): Detonate cannot be reached with a nonpositive radius. Fire only ever
|
||||
// freezes a Fuse when BlastRadius > 0 (12.4(a)), so this guard matters only for a direct caller (a
|
||||
// test, or Mine.Explode with a zero BlastRange).
|
||||
if (radius <= 0f) return;
|
||||
|
||||
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)
|
||||
foreach (var entity in zone.Entities)
|
||||
{
|
||||
var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize;
|
||||
var hullData = entity.ItemManager.GetData(entity.Hull) as HullData;
|
||||
var shape = hullData.Shape;
|
||||
|
||||
// Candidates: |Position.xz - P| <= radius + half the hull's own diagonal, in world units -- catches
|
||||
// a long hull whose end lies inside the blast even though its centre does not.
|
||||
var toEntity = length(entity.Position.xz - worldPlanar);
|
||||
var halfDiagonal = .5f * length(float2(shape.Width, shape.Height)) * cellSize;
|
||||
if (toEntity > radius + halfDiagonal) continue;
|
||||
|
||||
var centre = entity.ToSchematicPoint(worldPlanar);
|
||||
var rCells = radius / cellSize;
|
||||
var normaliser = PI * rCells * rCells;
|
||||
|
||||
// Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and
|
||||
// an item's pool deposits in.
|
||||
var coords = shape.Coordinates;
|
||||
var covered = new List<(int2 Cell, float Share)>();
|
||||
var totalShare = 0f;
|
||||
for (var i = 0; i < coords.Length; i++)
|
||||
{
|
||||
shield.TakeHit(damageType, damage);
|
||||
continue;
|
||||
var overlap = CircleSquareOverlap(centre, rCells, coords[i]);
|
||||
if (overlap <= 0f) continue;
|
||||
var share = damage * overlap / normaliser;
|
||||
covered.Add((coords[i], share));
|
||||
totalShare += share;
|
||||
}
|
||||
if (covered.Count == 0) continue;
|
||||
|
||||
var shield = entity.Shield;
|
||||
var shieldActive = shield != null && shield.Item.Active.Value;
|
||||
if (shieldActive)
|
||||
{
|
||||
if (shield.CanTakeHit(damageType, totalShare))
|
||||
{
|
||||
shield.TakeHit(damageType, totalShare);
|
||||
continue;
|
||||
}
|
||||
shield.Break();
|
||||
}
|
||||
|
||||
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
|
||||
var localDirection = lengthsq(toTarget) > 1e-6f
|
||||
? normalize(target.ToSchematic(toTarget))
|
||||
: float2(0, 1);
|
||||
// Armour first, per cell; an item's covered cells pool their post-armour deposits and resolve once,
|
||||
// from the pooled sum -- ItemAbsorb's own degenerate case for a pool of one covers a single-cell
|
||||
// item, exactly as it does in ApplyPooled.
|
||||
Dictionary<EquippedItem, List<float>> itemPools = null;
|
||||
var hullTotal = 0f;
|
||||
foreach (var (cell, share) in covered)
|
||||
{
|
||||
var postArmor = entity.ArmorAbsorb(cell, share);
|
||||
var item = entity.GearOccupancy[cell.x, cell.y];
|
||||
if (item == null) { hullTotal += postArmor; continue; }
|
||||
itemPools ??= new Dictionary<EquippedItem, List<float>>();
|
||||
if (!itemPools.TryGetValue(item, out var pool)) itemPools[item] = pool = new List<float>();
|
||||
pool.Add(postArmor);
|
||||
}
|
||||
|
||||
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;
|
||||
if (itemPools != null)
|
||||
foreach (var (item, pool) in itemPools)
|
||||
{
|
||||
var amounts = pool.ToArray();
|
||||
entity.ItemAbsorb(item, amounts);
|
||||
for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i];
|
||||
}
|
||||
|
||||
var cells = hitShape.Coordinates;
|
||||
var damagePerCell = damage / cells.Length;
|
||||
var hullDamage = 0f;
|
||||
foreach (var v in cells)
|
||||
hullDamage += target.Absorb(v, damagePerCell);
|
||||
target.DamageHull(hullDamage);
|
||||
entity.DamageHull(hullTotal);
|
||||
}
|
||||
}
|
||||
|
||||
// Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square,
|
||||
// centred on `cell` (the unit square centred on its integer coordinate, the same convention every other
|
||||
// frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same
|
||||
// (schematic-cell) units. Allocates nothing.
|
||||
private static float CircleSquareOverlap(float2 diskCentre, float r, int2 cell)
|
||||
{
|
||||
var xlo = cell.x - .5f - diskCentre.x;
|
||||
var xhi = cell.x + .5f - diskCentre.x;
|
||||
var ylo = cell.y - .5f - diskCentre.y;
|
||||
var yhi = cell.y + .5f - diskCentre.y;
|
||||
return RectDiskOverlap(xlo, xhi, ylo, yhi, r);
|
||||
}
|
||||
|
||||
// The area of intersection of a disc of radius r centred at the origin with the axis-aligned rectangle
|
||||
// [xlo,xhi] x [ylo,yhi]. Integrates the disc's chord height over the rectangle's x-span: for a fixed x, the
|
||||
// disc spans y in [-h,h] with h = sqrt(r^2 - x^2), and the covered length there is
|
||||
// min(yhi,h) - max(ylo,-h), clamped to zero. That covered length changes formula only where h(x) crosses
|
||||
// ylo or yhi (or at the rectangle's own edges), so the span is cut at those breakpoints and each piece is
|
||||
// integrated in closed form with AntiderivSqrt, the antiderivative of sqrt(r^2 - x^2).
|
||||
private static float RectDiskOverlap(float xlo, float xhi, float ylo, float yhi, float r)
|
||||
{
|
||||
if (r <= 0f) return 0f;
|
||||
xlo = max(xlo, -r);
|
||||
xhi = min(xhi, r);
|
||||
if (xlo >= xhi) return 0f;
|
||||
|
||||
Span<float> bp = stackalloc float[6];
|
||||
var n = 0;
|
||||
bp[n++] = xlo;
|
||||
bp[n++] = xhi;
|
||||
|
||||
// The breakpoints where h(x) = sqrt(r^2 - x^2) crosses ylo or yhi -- the only places the covered
|
||||
// length's formula (below) can change. No local function: a stackalloc Span cannot be captured by one.
|
||||
Span<float> ys = stackalloc float[2] { ylo, yhi };
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var y = ys[k];
|
||||
if (abs(y) >= r) continue;
|
||||
var xc = sqrt(r * r - y * y);
|
||||
if (-xc > xlo && -xc < xhi) bp[n++] = -xc;
|
||||
if (xc > xlo && xc < xhi) bp[n++] = xc;
|
||||
}
|
||||
|
||||
// Insertion sort: n is at most 6.
|
||||
for (var i = 1; i < n; i++)
|
||||
{
|
||||
var key = bp[i];
|
||||
var j = i - 1;
|
||||
while (j >= 0 && bp[j] > key) { bp[j + 1] = bp[j]; j--; }
|
||||
bp[j + 1] = key;
|
||||
}
|
||||
|
||||
var area = 0f;
|
||||
for (var i = 0; i < n - 1; i++)
|
||||
{
|
||||
var a = bp[i];
|
||||
var b = bp[i + 1];
|
||||
if (b - a < 1e-9f) continue;
|
||||
|
||||
var mid = .5f * (a + b);
|
||||
var h = sqrt(max(0f, r * r - mid * mid));
|
||||
var upperIsDisc = h < yhi;
|
||||
var lowerIsDisc = -h > ylo;
|
||||
var upper = upperIsDisc ? h : yhi;
|
||||
var lower = lowerIsDisc ? -h : ylo;
|
||||
if (upper <= lower) continue;
|
||||
|
||||
float piece;
|
||||
if (upperIsDisc && lowerIsDisc)
|
||||
piece = 2f * (AntiderivSqrt(b, r) - AntiderivSqrt(a, r));
|
||||
else if (upperIsDisc)
|
||||
piece = (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)) - ylo * (b - a);
|
||||
else if (lowerIsDisc)
|
||||
piece = yhi * (b - a) + (AntiderivSqrt(b, r) - AntiderivSqrt(a, r));
|
||||
else
|
||||
piece = (yhi - ylo) * (b - a);
|
||||
|
||||
area += piece;
|
||||
}
|
||||
return max(0f, area);
|
||||
}
|
||||
|
||||
// The antiderivative of sqrt(r^2 - x^2): x/2 * sqrt(r^2 - x^2) + r^2/2 * asin(x/r). Clamped into [-r,r]
|
||||
// first -- every caller here already clips its integration bounds into that range, so this is a guard
|
||||
// against float rounding at the boundary, not a second clipping rule.
|
||||
private static float AntiderivSqrt(float x, float r)
|
||||
{
|
||||
var xc = clamp(x, -r, r);
|
||||
return .5f * (xc * sqrt(max(0f, r * r - xc * xc)) + r * r * asin(xc / r));
|
||||
}
|
||||
|
||||
// Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's
|
||||
// reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the
|
||||
// discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian
|
||||
|
|
@ -1202,7 +1444,7 @@ public static class FireControl
|
|||
}
|
||||
|
||||
// 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.Absorb reads.
|
||||
// of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read.
|
||||
private static int2[] CellsOf(Entity target, EquippedItem item)
|
||||
{
|
||||
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
|
||||
|
|
|
|||
Loading…
Reference in a new issue