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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user