Aetheria/Assets/Scripts/Gameplay/ShieldInterceptor.cs
Meta Vens fb6062b8c6 Shield panel: gate reuse and Hit on the panel's own radius
The operator's play report on FieldShieldTest ("weak hits are very
janky, clicking outside the hex still makes it tremble and shatter,
pooling seems very rough") traced to two provable defects, not one
tuning constant:

- ShieldPanel.Hit had no bounds check at all. A point handed to it was
  always enqueued and injected regardless of how far outside the
  panel's own panelRadius it was.
- ShieldInterceptor.FindReusable decided reuse from a raw world-space
  chord distance to the panel's stale spawn-time Position, using
  PanelRadius*ReuseFraction (authored up to 2x) as the threshold. On a
  non-uniformly scaled/curved envelope a chord across curvature is not
  the panel's own in-plane extent, so a click clearly outside the
  rendered tiling could still measure inside threshold and get routed
  to Hit() -- which had nothing to reject it.
- Reusing a panel never refreshed its pooled DieAt, so a panel struck
  again could still expire and get returned to the pool mid-fracture
  ("pooling seems very rough").

Fix: ShieldPanel.ContainsWorldPoint is now the one owner of "is this
point within my own radius" (panel-local in-plane distance vs.
panelRadius), used both as Hit's hard gate and as FindReusable's
candidacy test (with ReuseFraction, now capped at 1, narrowing but
never widening past the physical radius). Reuse now also extends
DieAt from the moment of the new hit.

Pinned in ShieldPanelCut3Verify.CheckHitOutsideRadiusDoesNotReachPanel:
a direct Hit() and a routed Absorb() outside a panel's own radius must
enqueue nothing and leave its state buffer untouched, and a genuinely
outside click opens its own panel. Deterministic (checks the pending
queue and no-op state, not temper drift) because breakBudgetFraction
paces fracture over many frames on purpose, so temper keeps drifting
from an earlier hit alone -- a drift-based check would have been
measuring that, not this rule (caught this against the check's own
first run).

All four ShieldPanelCut<N>Verify probes pass (batchmode, real GPU
device, no -nographics).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:02:18 +02:00

198 lines
8.9 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using CultMath.UnityBridge;
using ShieldField;
// Cut 3 of docs/shield-panel-cut.md (R2): the shield is an interceptor. It converts each absorbed
// hit into a hard-light panel posed on the ship's ShieldEnvelope -- it decides nothing about the
// rule (R3/R6: the hits it shows are cosmetic, fire control decides what actually happened).
//
// Adds no pool: Assets/Scripts/Prototype.cs already is one (Instantiate<T>/ReturnToPool), and
// ShieldManager.ShowHit already uses it for exactly this short-lived per-hit shape.
public class ShieldInterceptor : MonoBehaviour, IAbsorbPresenter
{
public ShieldEnvelope Envelope;
public Prototype PanelPrototype; // a prefab carrying one ShieldPanel (Cut 4 rigs it)
public int MaxLivePanels = 12; // §2.3: ~0.12 ms CPU issuance per live panel
public float PanelRadius = 2f; // the SHIELD ITEM's size, not the hull's (R2)
public float EnergyScale = 1f;
// Cut 3 fix: capped at 1, not 2 -- ShieldPanel.ContainsWorldPoint (the one owner of "is this
// point within my own radius", shared by Hit's own strike gate) clamps the scale to at most 1,
// so a value above 1 was already silently inert. The old [0,2] range let you dial in a number
// that did nothing past 1; this range now tells the truth about what the knob controls.
[Range(0f, 1f)] public float ReuseFraction = 1f;
[Tooltip("Margin added on top of growDuration + shardLife before a panel with no further " +
"hits is released back to the pool (§3: a fixed lifetime, not a GPU readback).")]
public float DeathMargin = 0.25f;
public FracturePattern[] PatternByDamageType;
struct LivePanel
{
public ShieldPanel Panel;
public Prototype Proto;
public Vector3 Position;
public float SpawnedAt;
public float DieAt;
}
readonly List<LivePanel> _live = new(16);
void Awake()
{
// Cut 4 rigs the scene; this is the one push Cut 3 owns so the prototype's tiling is built
// at the item's size (R2: size is the item's, not the hull's) rather than whatever radius
// happens to be authored on the prefab. It only lands correctly if the prototype's own
// ShieldPanel has not already run its first OnEnable/Rebuild -- see the Cut 4 recipe note
// on starting PanelPrototype's GameObject inactive in the scene.
if (PanelPrototype != null)
{
var proto = PanelPrototype.GetComponent<ShieldPanel>();
if (proto != null) proto.panelRadius = PanelRadius;
}
_live.Capacity = Mathf.Max(_live.Capacity, MaxLivePanels);
}
void Update()
{
// Release timed-out panels. A GPU readback of "is anything still visible" would stall the
// pipeline every frame for every live panel (§3's cost note); a fixed lifetime, started at
// arm time, is the honest and cheap alternative -- same tradeoff the map makes for Death in
// ShieldPanel itself.
for (int i = _live.Count - 1; i >= 0; i--)
{
if (Time.time < _live[i].DieAt) continue;
_live[i].Proto.ReturnToPool();
_live.RemoveAt(i);
}
}
public int LiveCount => _live.Count;
/// <summary>Cut 3 verification surface (Q7: no NUnit path for these files) -- lets the
/// batchmode probe step and read back a specific live panel's simulation without duplicating
/// the interceptor's own spawn/reuse/pose logic.</summary>
public ShieldPanel DebugPanelAt(int i) => _live[i].Panel;
// IAbsorbPresenter. The event is enough (map §"The event is enough"): project the event's
// world-space position onto the envelope, pose a panel there with the envelope's true surface
// normal, strike whichever panel owns that point.
public void Absorb(AbsorbEvent e)
{
if (Envelope == null || PanelPrototype == null) return;
Vector3 worldPos = e.Position.ToUnity();
Vector3 intercept = Envelope.ProjectToSurface(worldPos);
Vector3 dir = e.Direction.ToUnity();
float energy = e.Magnitude * EnergyScale;
var pattern = PatternFor(e.DamageType);
int reuseIndex = FindReusable(intercept);
if (reuseIndex >= 0)
{
// Reuse: a second hit landing inside a living panel strikes THAT panel rather than
// spawning a new one -- this is the rule that makes temper erosion (ShieldSim.compute's
// multi-hit budget) visible at all. No ResetSim here: that would wipe the very state
// this rule exists to preserve.
var lp = _live[reuseIndex];
lp.Panel.Hit(intercept, dir, energy, pattern);
// Cut 3 fix: DieAt was set once at spawn time and never refreshed on reuse, so a panel
// that keeps getting struck could still expire and get pooled mid-fracture -- the
// operator's "pooling seems very rough". A struck-again panel is exactly as alive as one
// freshly spawned right now; give it the same margin from THIS hit.
lp.DieAt = Time.time + lp.Panel.growDuration + lp.Panel.shardLife + DeathMargin;
_live[reuseIndex] = lp;
return;
}
ShieldPanel panel;
Prototype panelProto;
if (_live.Count >= MaxLivePanels)
{
// Pool exhausted: recycle the oldest live panel in place rather than drop the strike
// (one event, one strike) or grow past the §2.3 frame budget. The oldest is the one
// furthest through its own fade. Reused directly (not routed through
// Prototype.ReturnToPool + Instantiate) because the interceptor already holds the
// exact instance to reassign; a round trip through the pool's free list would only add
// SetParent/SetActive churn for the same outcome.
int oldest = OldestIndex();
panel = _live[oldest].Panel;
panelProto = _live[oldest].Proto;
_live.RemoveAt(oldest);
}
else
{
panelProto = PanelPrototype.Instantiate<Prototype>();
panel = panelProto.GetComponent<ShieldPanel>();
}
var normal = Envelope.SurfaceNormal(intercept);
var t = panel.transform;
t.position = intercept;
t.rotation = Quaternion.LookRotation(normal);
// Scale is never touched here: R2/Cut 3 require it stay 1, which is a property of the
// prototype's own authored transform and its parent chain (Cut 4's rig), not something
// this component enforces at pose time -- parenting under the envelope's non-uniform
// Shield-prefab scale would skew every shard (§ Cut 3 "Scale must be 1").
panel.ResetSim();
panel.Hit(intercept, dir, energy, pattern);
_live.Add(new LivePanel
{
Panel = panel,
Proto = panelProto,
Position = intercept,
SpawnedAt = Time.time,
DieAt = Time.time + panel.growDuration + panel.shardLife + DeathMargin
});
}
// Cut 3 fix: this used to compare a raw world-space chord distance from the panel's stale
// spawn-time Position against PanelRadius*ReuseFraction. On the envelope's own documented
// non-uniform ellipsoid (ShieldEnvelope.cs's Longinus/FieldShieldTest numbers), a chord across
// curvature is not the same length as the panel's own in-plane extent -- a click clearly outside
// the rendered tiling could still chord-measure inside the threshold and get routed to Hit(),
// which had no bounds check of its own and dutifully processed it (the operator's "clicking
// outside the hex still makes it tremble and shatter"). ShieldPanel.ContainsWorldPoint is the
// one owner of "is this point within my own radius" now (Hit() gates on it too), so reuse
// selection and the strike gate can never disagree.
int FindReusable(Vector3 point)
{
int best = -1;
float bestDist2 = float.MaxValue;
for (int i = 0; i < _live.Count; i++)
{
if (!_live[i].Panel.ContainsWorldPoint(point, ReuseFraction)) continue;
float d2 = (_live[i].Position - point).sqrMagnitude;
if (d2 < bestDist2)
{
bestDist2 = d2;
best = i;
}
}
return best;
}
int OldestIndex()
{
int oldest = 0;
float oldestTime = float.MaxValue;
for (int i = 0; i < _live.Count; i++)
{
if (_live[i].SpawnedAt < oldestTime)
{
oldestTime = _live[i].SpawnedAt;
oldest = i;
}
}
return oldest;
}
FracturePattern PatternFor(DamageType t)
{
int i = (int)t;
if (PatternByDamageType != null && i >= 0 && i < PatternByDamageType.Length)
return PatternByDamageType[i];
return FracturePattern.Radial;
}
}