Add ShieldInterceptor: the interceptor presenter and panel pool (Cut 3)

R2: the shield is an interceptor. ShieldInterceptor implements IAbsorbPresenter
and converts each AbsorbEvent into a pose (intercept point projected onto the
ShieldEnvelope, normal from SurfaceNormal, size from the item's PanelRadius)
and a strike, deciding nothing else. It reuses Assets/Scripts/Prototype.cs
rather than writing a second pool.

Reuse: a second hit landing within PanelRadius*ReuseFraction of a live panel
strikes that panel (Hit only, no ResetSim) instead of spawning a new one --
this is what makes ShieldSim.compute's temper erosion observable across
multiple hits.

Pool exhaustion: when MaxLivePanels is already live, the oldest live panel
(by spawn order) is recycled in place -- reposed and re-armed for the new
intercept point -- rather than dropping the strike or growing past the
§2.3 frame budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-18 13:51:26 +02:00
commit 0e2fe6dc11
2 changed files with 181 additions and 0 deletions

View file

@ -0,0 +1,179 @@
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;
[Range(0f, 2f)] 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.
_live[reuseIndex].Panel.Hit(intercept, dir, energy, pattern);
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
});
}
int FindReusable(Vector3 point)
{
float threshold = PanelRadius * ReuseFraction;
float threshold2 = threshold * threshold;
int best = -1;
float bestDist2 = float.MaxValue;
for (int i = 0; i < _live.Count; i++)
{
float d2 = (_live[i].Position - point).sqrMagnitude;
if (d2 < threshold2 && 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;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fdca6a832d764b6e89fc83fdcf7b42d7