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
+180
View File
@@ -0,0 +1,180 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &803404312977859260
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 544753127400808733}
- component: {fileID: 8071270979468917903}
- component: {fileID: 4362678855125273864}
m_Layer: 0
m_Name: Model
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &544753127400808733
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 803404312977859260}
serializedVersion: 2
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0.4, z: 0}
m_LocalScale: {x: 1.3, y: 1.3, z: 1.3}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3572766465862231365}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &8071270979468917903
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 803404312977859260}
m_Mesh: {fileID: 4300000, guid: a52e2d101bf32694d9012060774e97fd, type: 3}
--- !u!23 &4362678855125273864
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 803404312977859260}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!1 &3885353946372160549
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3572766465862231365}
- component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220}
- component: {fileID: 6800460489587750746}
m_Layer: 0
m_Name: EnemySpit
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3572766465862231365
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 544753127400808733}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &9053853372340598254
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
--- !u!114 &6834786618115927220
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
HasOwner: 0
SupportAutoCommandTarget: 1
TrackInterpolationDelay: 0
GhostGroup: 0
UsePreSerialization: 0
UseSingleBaseline: 0
RollbackPredictedSpawnedGhostState: 0
RollbackPredictionOnStructuralChanges: 1
DefaultGhostMode: 0
SupportedGhostModes: 3
OptimizationMode: 0
Importance: 1
MaxSendRate: 0
prefabId:
--- !u!114 &6800460489587750746
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: ff79c8fbcacb8c34faad37d59836b5ac, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyProjectileAuthoring
Speed: 11
Damage: 8
Range: 16
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8a13ef653e5266741b0896084f74038f
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f29273bdfc85a8b49a4e16719e9a2568
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b0921d4cb0eedd349a257759a157653a
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
+69 -2
View File
@@ -2536,15 +2536,25 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.WaveDirectorAuthoring
EnemyPrefabs:
- {fileID: 3885353946372160549, guid: a6c2004a3cc32cc44b1bb7a795f86519, type: 3}
- {fileID: 3885353946372160549, guid: f77a36036567c814496e6c59c42b2082, type: 3}
- {fileID: 3885353946372160549, guid: 31d233e9e507acf45a411f8ab0997bed, type: 3}
- {fileID: 3885353946372160549, guid: 0d42b4a8ef76489458ee1ecf51b4dbca, type: 3}
- {fileID: 3885353946372160549, guid: f29273bdfc85a8b49a4e16719e9a2568, type: 3}
- {fileID: 3885353946372160549, guid: b0921d4cb0eedd349a257759a157653a, type: 3}
RingRadius: 16
RingSlots: 10
BaseCount: 4
CountPerWave: 2
SpawnIntervalTicks: 24
LullTicks: 240
MaxAlive: 14
ChargerBase: 0
SpitterBase: 0
SwarmerSlotBase: 0
ChargerPerEpoch: 1
SpitterPerEpoch: 1
SwarmerSlotPerEpoch: 0
SwarmerPackSize: 3
SwarmerPackPerEpoch: 0
ClusterTightRadius: 2.5
--- !u!1 &1379903944
GameObject:
m_ObjectHideFlags: 0
@@ -3160,6 +3170,52 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 3.5, y: 8, z: 3.5}
m_Center: {x: 0, y: 4, z: 0}
--- !u!1 &1871304248
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1871304250}
- component: {fileID: 1871304249}
m_Layer: 0
m_Name: SpitterProjectileConfig
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1871304249
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1871304248}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4ce5223c5fd56694c81991e1bb1232de, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterProjectilePrefabAuthoring
ProjectilePrefab: {fileID: 3885353946372160549, guid: 8a13ef653e5266741b0896084f74038f, type: 3}
MaxLiveProjectiles: 24
--- !u!4 &1871304250
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1871304248}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1906259338
GameObject:
m_ObjectHideFlags: 0
@@ -3344,12 +3400,22 @@ MonoBehaviour:
EnemyPrefabs:
- {fileID: 3885353946372160549, guid: a6c2004a3cc32cc44b1bb7a795f86519, type: 3}
- {fileID: 3885353946372160549, guid: 0d42b4a8ef76489458ee1ecf51b4dbca, type: 3}
- {fileID: 3885353946372160549, guid: f29273bdfc85a8b49a4e16719e9a2568, type: 3}
- {fileID: 3885353946372160549, guid: b0921d4cb0eedd349a257759a157653a, type: 3}
MaxAlive: 12
RingRadius: 14
RingSlots: 10
SpawnIntervalTicks: 30
GruntsPerWave: 4
ChargersPerWave: 1
SpitterBase: 0
SwarmerSlotBase: 0
ChargerPerEpoch: 1
SpitterPerEpoch: 1
SwarmerSlotPerEpoch: 1
SwarmerPackSize: 4
SwarmerPackPerEpoch: 0
ClusterTightRadius: 2.5
RewardOre: 25
--- !u!4 &1957070224
Transform:
@@ -3748,3 +3814,4 @@ SceneRoots:
- {fileID: 722706770}
- {fileID: 2145598870}
- {fileID: 1957070224}
- {fileID: 1871304250}
@@ -0,0 +1,205 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 system tests for the EnemyAISystem SPITTER pass (server-only, plain SimulationSystemGroup). Covers the
/// headline ranged mechanic end-to-end: an in-band, ready Spitter commits a telegraphed wind-up then on elapse
/// spawns a spit carrying the FIRING Spitter's Region (fired from EXPEDITION so a dropped Region copy — which would
/// leave the prefab default 0 = Base — fails the assertion), aimed at the target. The HOLD-RANGE gate (DR-041) is
/// pinned by negative tests: a Spitter ADVANCING from out of band does NOT telegraph; a cornered Spitter fires
/// point-blank. The discriminator partition (no double-move) is asserted DIRECTLY (the wind-up value alone can't
/// prove it — the Spitter pass runs last and overwrites it). Soft-fail over the concurrent cap = short retry, no
/// full-cooldown burn. Plain-Entities world, faked NetworkTime + a SpitterProjectilePrefab singleton; the prefab
/// entity is Prefab-tagged so it is excluded from the live-spit count and cloned (minus the tag) on Instantiate.
/// </summary>
public class SpitterBrainTests
{
static void SetTick(World w, uint tick)
{
var em = w.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World, SimulationSystemGroup) AiWorld(uint tick)
{
var w = new World("SpitterBrain");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyAISystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetTick(w, tick);
return (w, g);
}
static Entity MakeSpitPrefab(EntityManager em, float range = 16f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(float3.zero));
em.AddComponentData(e, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Damage = 0f, Range = range, Region = 0 });
em.AddComponent<Prefab>(e); // excluded from the live-spit query; stripped on Instantiate
return e;
}
static void SetSpitSingleton(EntityManager em, Entity prefab, int maxLive)
{
var s = em.CreateEntity(typeof(SpitterProjectilePrefab));
em.SetComponentData(s, new SpitterProjectilePrefab { Prefab = prefab, MaxLiveProjectiles = maxLive });
}
static Entity MakeSpitter(EntityManager em, float3 pos, byte region, int windupTicks = 1, int cooldown = 60)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponent<EnemyTag>(e);
em.AddComponentData(e, new EnemyStats { MoveSpeed = 4f, AttackRange = 1.5f, AttackDamage = 8f, AttackCooldownTicks = cooldown });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0u });
em.AddComponentData(e, new KnockbackState { Dir = default, Speed = 0f, UntilTick = 0u });
em.AddComponentData(e, new AttackWindup { WindUpUntilTick = 0u });
em.AddComponentData(e, new SpitterState { PreferredRange = 9f, RangeTolerance = 1.5f, ProjectileSpeed = 11f, CorneredRange = 3f, WindupTicks = windupTicks, NextShotTick = 0u });
em.AddComponentData(e, new RegionTag { Region = region });
return e;
}
static void MakePlayer(EntityManager em, float3 pos, byte region)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponent<PlayerTag>(e);
}
static int CountSpits(EntityManager em)
{
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
return q.CalculateEntityCount();
}
[Test]
public void Spitter_InBand_CommitsThenFires_SpitCarriesFiringRegion()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
var prefab = MakeSpitPrefab(em, range: 16f);
SetSpitSingleton(em, prefab, maxLive: 24);
// Fire from EXPEDITION (!=0): a dropped Region copy would leave 0 (Base) and fail the region assert.
MakePlayer(em, new float3(0, 1, 0), RegionId.Expedition);
var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Expedition, windupTicks: 1); // distance == PreferredRange -> in-band
g.Update(); // tick 200: in-band + ready -> commit the telegraph wind-up to 201
Assert.AreEqual(TickUtil.NonZero(201u), em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"an in-band, ready Spitter commits a wind-up of SpitterState.WindupTicks (the partition itself is asserted in Spitter_IsExcludedFromGruntAndChargerPasses)");
Assert.AreEqual(0, CountSpits(em), "no spit yet — still telegraphing the dodge window");
SetTick(w, 202); // the wind-up tick (201) has now elapsed
g.Update();
Assert.AreEqual(1, CountSpits(em), "the spit fires when the wind-up elapses in-band");
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
var spit = q.GetSingleton<EnemyProjectile>();
Assert.AreEqual(RegionId.Expedition, spit.Region, "the spit carries the FIRING Spitter's region (Expedition!=0 -> a dropped copy fails this)");
Assert.Less(spit.Direction.x, 0f, "aimed back toward the player at the origin");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick, "the wind-up is cleared after firing");
}
}
[Test]
public void Spitter_OutOfBand_DoesNotCommitWindup()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(40, 1, 0), RegionId.Base, windupTicks: 1); // dist 40 >> PreferredRange+tol -> advancing
g.Update();
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"a Spitter ADVANCING from out of band must NOT telegraph/fire (the hold-range gate, DR-041)");
}
}
[Test]
public void Spitter_Cornered_CommitsWindupPointBlank()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(2, 1, 0), RegionId.Base, windupTicks: 5); // dist 2 < CorneredRange 3 -> point-blank
g.Update();
Assert.AreNotEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"a cornered Spitter (target inside CorneredRange) fires point-blank rather than holding fire");
}
}
[Test]
public void Spitter_IsExcludedFromGruntAndChargerPasses()
{
var w = new World("SpitterRouting");
using (w)
{
var em = w.EntityManager;
MakeSpitter(em, new float3(9, 1, 0), RegionId.Base);
// The three EnemyAISystem pass partitions, asserted directly so a regression in any WithNone guard is caught.
using var gruntQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<LungeState>(), ComponentType.ReadOnly<SpitterState>() },
});
using var chargerQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<SpitterState>() },
});
using var spitterQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<SpitterState>() },
None = new[] { ComponentType.ReadOnly<LungeState>() },
});
Assert.AreEqual(0, gruntQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Grunt pass (WithNone<LungeState,SpitterState>)");
Assert.AreEqual(0, chargerQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Charger pass (WithNone<SpitterState>)");
Assert.AreEqual(1, spitterQ.CalculateEntityCount(), "a Spitter IS visited by exactly the Spitter pass");
}
}
[Test]
public void Spitter_OverSoftCap_SkipsFire_ShortRetryNoCooldownBurn()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
var prefab = MakeSpitPrefab(em);
SetSpitSingleton(em, prefab, maxLive: 2);
// pre-fill the live-spit pool to the cap (no Prefab tag -> counted by the soft-cap query)
for (int i = 0; i < 2; i++)
{
var s = em.CreateEntity();
em.AddComponentData(s, LocalTransform.FromPosition(new float3(i, 1, 0)));
em.AddComponentData(s, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Range = 16f, Region = RegionId.Base });
}
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Base, windupTicks: 1, cooldown: 60);
g.Update(); // tick 200: in-band -> commit the wind-up to 201
SetTick(w, 202); // elapsed
g.Update(); // at the cap -> soft-fail
Assert.AreEqual(2, CountSpits(em), "at the concurrent cap the Spitter does NOT spawn another spit");
Assert.AreEqual(TickUtil.NonZero(210u), em.GetComponentData<SpitterState>(spitter).NextShotTick,
"soft-fail schedules a short retry (now+8 = 210), NOT a full cooldown (now+60 = 262)");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2fc1d0d0241a80745a308575f71c701b
@@ -0,0 +1,114 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 system tests for the SWARMER cluster spawn in the base-siege WaveSystem (fork 4a). A swarmer composition
/// slot must instantiate a whole PACK in one tick (EnemyAIMath.ClusterOffset) while consuming exactly ONE wave
/// SLOT, and MaxAlive must count ENTITIES — a pack that won't fit is DEFERRED (slot kept) rather than partially
/// spawned (the review-flagged slot-vs-entity accounting). Plain-Entities world, server WaveSystem registered
/// directly, faked NetworkTime; the 4-entry [Grunt,Charger,Spitter,Swarmer] roster is Prefab-tagged so the
/// instances (and only the instances) count as live EnemyTag ghosts.
/// </summary>
public class SwarmerClusterSpawnTests
{
static void SetTick(World w, uint tick)
{
var em = w.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World, SimulationSystemGroup) WaveWorld(uint tick)
{
var w = new World("SwarmerCluster");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<WaveSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetTick(w, tick);
return (w, g);
}
// Director with a swarmer-only band so slot 0 is unambiguously a swarmer pack. The 4-entry roster aliases
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
static Entity MakeDirector(EntityManager em, int swarmerSlotBase, int packSize, int maxAlive)
{
// Create the 4 prefab entities FIRST: every em.CreateEntity is a structural change, so a DynamicBuffer
// handle grabbed before them would be invalidated (the bug this ordering avoids). The roster aliases
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
var prefabs = new Entity[4];
for (int i = 0; i < 4; i++)
{
var p = em.CreateEntity();
em.AddComponentData(p, LocalTransform.FromPosition(float3.zero));
em.AddComponent<EnemyTag>(p);
em.AddComponent<Prefab>(p);
prefabs[i] = p;
}
var dir = em.CreateEntity();
em.AddComponentData(dir, new WaveDirector
{
RingRadius = 10f, RingSlots = 8, BaseCount = 0, CountPerWave = 0,
SpawnIntervalTicks = 1, LullTicks = 1, MaxAlive = maxAlive,
ChargerBase = 0, SpitterBase = 0, SwarmerSlotBase = swarmerSlotBase,
ChargerPerEpoch = 0, SpitterPerEpoch = 0, SwarmerSlotPerEpoch = 0,
SwarmerPackSize = packSize, SwarmerPackPerEpoch = 0, ClusterTightRadius = 2.5f,
});
em.AddComponentData(dir, new WaveState { WaveNumber = 0, Phase = WavePhase.Lull, NextActionTick = 0u, RemainingToSpawn = 0, SpawnCounter = 0 });
// AddBuffer LAST (after every structural change on dir), then populate with no further structural change.
var buf = em.AddBuffer<WaveEnemyPrefab>(dir);
for (int i = 0; i < 4; i++) buf.Add(new WaveEnemyPrefab { Prefab = prefabs[i] });
return dir;
}
static int CountEnemies(EntityManager em)
{
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyTag>());
return q.CalculateEntityCount();
}
[Test]
public void Swarmer_Slot_SpawnsWholePack_ConsumesOneSlot()
{
var (w, g) = WaveWorld(200);
using (w)
{
var em = w.EntityManager;
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 12);
g.Update(); // Lull -> start wave: RemainingToSpawn = WaveSlots(1) = 1 swarmer slot
Assert.AreEqual(1, em.GetComponentData<WaveState>(dir).RemainingToSpawn, "one swarmer SLOT this wave");
g.Update(); // Spawning -> the pack lands in one tick
Assert.AreEqual(4, CountEnemies(em), "the whole pack spawns in a single tick");
var st = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(1, st.SpawnCounter, "exactly ONE slot consumed for the pack");
Assert.AreEqual(0, st.RemainingToSpawn, "the swarmer slot is done");
}
}
[Test]
public void Swarmer_PackOverMaxAlive_Defers_KeepsSlot()
{
var (w, g) = WaveWorld(200);
using (w)
{
var em = w.EntityManager;
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 3); // pack(4) > cap(3)
g.Update(); // start wave
g.Update(); // try to spawn -> 0 + 4 > 3 -> defer (don't partially spawn, don't consume the slot)
Assert.AreEqual(0, CountEnemies(em), "a pack that won't fit MaxAlive is NOT partially spawned");
var st = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(0, st.SpawnCounter, "the slot is NOT consumed when deferred");
Assert.AreEqual(1, st.RemainingToSpawn, "the swarmer slot remains pending");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96771355c799615499f6455055b66ca8