Slice Combat Depth (MC-3 + wiring + review fixes): Spitter aim-line + player-hit punch, rigged enemies, in-band gate (DR-041)

Completes the Combat Depth slice on top of the MC-2 server spine (56cf60cce):

MC-3 impact juice (client, observe-only):
- 7 FeelConfig fields + ResetDefaults; magnitude-scaled player-dealt-hit camera
  PunchFov on the enemy-Health-decrease edge (camera-only hit-stop, never timeScale).
- Spitter Kind==2 aim-LANE telegraph (BuildLaneMesh) — reads baked SpitterState
  client-side, falls back to a fixed length. True freeze + material flash deferred.

Content / wiring:
- SpitterProjectilePrefabAuthoring (the SpitterProjectilePrefab singleton).
- Both directors rebuilt to a 4-entry KIND-INDEXED roster [Grunt,Charger,Spitter,
  Swarmer] + mix/MaxAlive config + the SpitterProjectileConfig singleton in the subscene.
- Real rigged models: EnemySpitter (re-skinned Kaiju, ranged poker) + EnemySwarmerUndead
  (Undead-Werewolf, fast/low-HP); grunt/charger keep Werewolf/ChargerMuscle. EnemySpit =
  ownerless interpolated ghost (no Health, no collider).

Post-impl adversarial review fixes (wf_febdcfdb-665):
- [MED] in-band fire gate: the Spitter committed its telegraph from ANY range (fired while
  advancing from far). Now commits only when sInBand || sCornered (gives CorneredRange a
  real read site) — a Spitter out-of-band holds fire and repositions.
- [LOW] EnemyProjectileDamageSystem early-returns on !ServerTick.IsValid (sibling parity).
- [LOW] EnemyAuthoring bake-time guard: errors if a prefab composes both Charger+Spitter
  (would match zero AI passes -> never move).
- [LOW] tests: Spitter brain fires from Expedition (kills the Base==0 region false-green);
  a direct partition-exclusion test replaces the order-masked claim; added out-of-band +
  cornered negative tests.

388/388 EditMode green + two Play smokes (clean boot, fire, swept-hit, region, server==
client; rigged Kaiju spitter bakes + fires with zero console errors). Accepted as-is
(documented in DR-041): global spit soft-cap, co-op punch attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 21:08:59 -07:00
parent 56cf60cce3
commit e32dadbc66
20 changed files with 5907 additions and 16 deletions
@@ -61,6 +61,10 @@ namespace ProjectM.Authoring
byte kind = ZoneEnemyMath.KindGrunt;
byte windup = (byte)Tuning.AttackWindupTicks;
var spitter = GetComponent<SpitterAuthoring>();
// Bake-time guard (DR-041 sole-Position-writer invariant): a prefab must carry at most ONE of
// {ChargerAuthoring(LungeState), SpitterAuthoring(SpitterState)} — both would match ZERO AI passes.
if (GetComponent<ChargerAuthoring>() != null && spitter != null)
Debug.LogError($"Enemy '{authoring.name}' has BOTH ChargerAuthoring and SpitterAuthoring; it would match no AI pass and never move. Remove one.", authoring);
if (GetComponent<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; }
else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); }
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; }
@@ -0,0 +1,36 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — authoring for the <see cref="SpitterProjectilePrefab"/> subscene singleton. Place ONE on a GameObject in
/// the gameplay subscene; the server EnemyAISystem Spitter pass reads it via <c>GetSingleton</c> to know which spit
/// ghost to instantiate and the concurrent soft-cap. The referenced prefab is the EnemyProjectile ghost
/// (EnemyProjectileAuthoring); MaxLiveProjectiles bounds the relevancy loop — a Spitter at/over the cap soft-fails
/// its shot (no cooldown burn). The carrying entity has no transform; only the referenced prefab needs one.
/// </summary>
public class SpitterProjectilePrefabAuthoring : MonoBehaviour
{
[Tooltip("The EnemyProjectile ghost prefab that Spitters fire (must carry EnemyProjectileAuthoring + an interpolated GhostAuthoringComponent).")]
public GameObject ProjectilePrefab;
[Min(1), Tooltip("Max concurrent live spit projectiles across all Spitters (soft-cap; over it a Spitter soft-fails its shot).")]
public int MaxLiveProjectiles = 24;
private class SpitterProjectilePrefabBaker : Baker<SpitterProjectilePrefabAuthoring>
{
public override void Bake(SpitterProjectilePrefabAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new SpitterProjectilePrefab
{
Prefab = authoring.ProjectilePrefab != null
? GetEntity(authoring.ProjectilePrefab, TransformUsageFlags.Dynamic) : Entity.Null,
MaxLiveProjectiles = authoring.MaxLiveProjectiles,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ce5223c5fd56694c81991e1bb1232de
@@ -217,7 +217,15 @@ namespace ProjectM.Client
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isEnemy) ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
if (isEnemy)
{
// MC-3: net-new player-dealt-hit camera punch — scales with the bite size
// (saturate(delta / RefDamage)) so a chip reads soft and a heavy connect snaps.
// Camera-only hit-stop (NEVER Time.timeScale); keys on the enemy Health-decrease edge.
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
}
}
// Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing
@@ -739,7 +747,20 @@ namespace ProjectM.Client
}
float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f);
if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter)
{
// MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim
// LANE along its (face-locked) facing out to projectile reach during wind-up, brightening as the
// shot nears so the player reads the line to dodge/dash across it.
float laneLen = 12f;
if (SystemAPI.HasComponent<SpitterState>(entity))
{
var ss = SystemAPI.GetComponent<SpitterState>(entity);
laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f);
}
BuildLaneMesh(go.GetComponent<MeshFilter>().sharedMesh, laneLen, 0.28f, intensity);
}
else BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation);
var tr = go.transform;
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f;
@@ -892,6 +913,33 @@ namespace ProjectM.Client
mesh.RecalculateBounds();
}
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
// already rotated to the enemy facing, so +Z is "toward the locked target".
static void BuildLaneMesh(Mesh mesh, float length, float halfWidth, float intensity)
{
float a = 0.18f + 0.62f * intensity;
var verts = new Vector3[4]
{
new Vector3(-halfWidth, 0f, 0.2f),
new Vector3( halfWidth, 0f, 0.2f),
new Vector3(-halfWidth, 0f, length),
new Vector3( halfWidth, 0f, length),
};
var cols = new Color[4]
{
new Color(1f, 1f, 1f, a),
new Color(1f, 1f, 1f, a),
new Color(1f, 1f, 1f, a * 0.12f),
new Color(1f, 1f, 1f, a * 0.12f),
};
var uvs = new Vector2[4] { new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f) };
var tris = new int[6] { 0, 2, 1, 1, 2, 3 };
mesh.Clear();
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
mesh.RecalculateBounds();
}
static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
{
if (ps == null) return;
@@ -34,6 +34,22 @@ namespace ProjectM.Client
/// <summary>Milliseconds the FOV kick eases back to base.</summary>
public static float HitStopDurationMs;
// ---- MC-3: player-dealt-hit punch (magnitude-scaled) + deferred enemy flash ----
/// <summary>Min FOV kick (deg) when the LOCAL player lands a hit on an enemy (chip damage).</summary>
public static float HitStopFovKickMin;
/// <summary>Max FOV kick (deg) on a heavy player-dealt hit (delta >= HitStopRefDamage).</summary>
public static float HitStopFovKickMax;
/// <summary>Reserved cap for the (deferred) true freeze-frame, in fixed frames.</summary>
public static int HitStopMaxFrames;
/// <summary>Damage delta that saturates the player-dealt punch to HitStopFovKickMax.</summary>
public static float HitStopRefDamage;
/// <summary>Tint for the (deferred) enemy material hit-flash — exposed now, wired in the ShaderGraph slice.</summary>
public static Color HitFlashColor;
/// <summary>Duration (ms) of the deferred enemy hit-flash.</summary>
public static float HitFlashDurationMs;
/// <summary>Master gate for the (deferred) true freeze-frame hit-stop. FALSE for v1 (camera-punch only).</summary>
public static bool HitStopFreezeEnabled;
// ---- Feature 1/2: death camera punch ----
/// <summary>Camera shake on LOCAL player death (loudest event by design).</summary>
public static float PlayerDeathShake;
@@ -103,6 +119,13 @@ namespace ProjectM.Client
HitSfxVolume = 0.70f;
HitStopFovKick = 1.5f;
HitStopDurationMs = 90f;
HitStopFovKickMin = 0.6f;
HitStopFovKickMax = 2.2f;
HitStopMaxFrames = 3;
HitStopRefDamage = 30f;
HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f);
HitFlashDurationMs = 80f;
HitStopFreezeEnabled = false;
// Feature 1/2 death
PlayerDeathShake = 0.50f;
@@ -432,7 +432,13 @@ namespace ProjectM.Server
else
{
bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick);
if (sReady && (sTargetEntity != Entity.Null || sCoreAlive))
// In-band gate (DR-041): telegraph + fire ONLY when holding the preferred band, OR when the target has
// closed inside CorneredRange (point-blank, no retreat room). While ADVANCING from too far OR
// RETREATING from a too-close target it must NOT fire — that IS the hold-range "reposition" question.
float sDist = math.length(sToTarget);
bool sInBand = math.abs(sDist - sp.PreferredRange) <= sp.RangeTolerance;
bool sCornered = sDist <= sp.CorneredRange;
if (sReady && (sInBand || sCornered) && (sTargetEntity != Entity.Null || sCoreAlive))
{
uint wTicks = (uint)math.max(1, sp.WindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
@@ -44,7 +44,9 @@ namespace ProjectM.Server
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid) return; // mirror WaveSystem/ZoneEnemyDirectorSystem — never stamp SourceTick off an invalid tick
uint now = serverTick.TickIndexForValidTick;
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring);