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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Combat Depth Slice — Enemy Variety (MC-2) + Impact (MC-3) — Build
|
||||
date: 2026-06-24
|
||||
tags: [session, combat, enemies, netcode, juice, slice, rukhanka]
|
||||
permalink: gamevault/07-sessions/2026/2026-06-24-combat-depth-build
|
||||
---
|
||||
|
||||
# Combat Depth (MC-2 + MC-3) — Build session
|
||||
|
||||
Built the combat-depth slice the operator chose after Slice 3 ("the combat needs a lot more work"). Spec + forks + the full build record are in [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]]. This log is the build narrative.
|
||||
|
||||
## What shipped
|
||||
- **Two new enemy questions:** the **Spitter** (ranged reposition — holds a preferred range-band, fires a telegraphed dodgeable spit) and the **Swarmer** (surround — deterministic cluster spawn). On top of the existing Grunt (walk-up melee) + Charger (committed lunge) → four distinct readable questions.
|
||||
- **4-type weighted mix** in BOTH directors (expedition `ZoneEnemyDirector` + base-siege `WaveSystem`, fork-4a) sharing one pure `ZoneEnemyMath` composition function, with the mandatory `MaxAlive` cap. Legacy size/charger curve kept + parity-tested.
|
||||
- **MC-3 impact:** magnitude-scaled player-dealt-hit camera punch + the Spitter aim-LANE telegraph (camera-only hit-stop, never `Time.timeScale`).
|
||||
- **Real rigged enemies:** Spitter = a re-skinned rigged Kaiju, Swarmer = a rigged Undead-Werewolf (fast/low-HP); grunt/charger unchanged. Spit projectile = `EnemySpit` ownerless interpolated ghost.
|
||||
|
||||
## How it went (verify ladder)
|
||||
- **MC-2 server spine** committed first as `56cf60cce` (already green at the time).
|
||||
- **MC-3 + tests + rigging + wiring** built via MCP. **388/388 EditMode** (added Spitter-brain, swarmer-cluster, projectile, mix, and 3 review-driven guard tests).
|
||||
- **Play smoke (×2):** clean two-world boot (no `ComponentSystemSorter` cycle, no Burst ICE), Spitter fires → spit sweeps → player damaged (HP 105→25) → `DamageEvent` drained, region-correct, **replicated server→client**. Re-ran with the rigged Kaiju spitter after the fix: spawns + fires + damages, **zero console errors** (Rukhanka rig baked intact).
|
||||
|
||||
## Post-impl adversarial review (`wf_febdcfdb-665`)
|
||||
3 lenses (netcode/relevancy · determinism/prediction · reuse/test-validity) → per-finding refutation. Caught **1 MED + 5 LOW**:
|
||||
- **[MED, FIXED] Spitter fired from any range** — the wind-up commit lacked the locked in-band gate, so it would telegraph+fire while still advancing from far (defeating the hold-range question). Fixed: commit only when `sInBand || sCornered` (which also gave the previously-dead `CorneredRange` a real read site). The review's value re-confirmed — a true behavioral deviation 388 green tests + a clean Play had NOT caught.
|
||||
- **[LOW, FIXED]** `EnemyProjectileDamageSystem` missing `!ServerTick.IsValid` guard; bake-time guard against a Charger+Spitter prefab; two test false-greens (Base==0 region assert → fire from Expedition; overstated partition claim → a direct partition-exclusion test).
|
||||
- **[LOW, DOCUMENTED]** global (not per-region) spit soft-cap; co-op camera-punch attribution. Both per the locked design / generous bounds.
|
||||
|
||||
## Gotchas worth remembering
|
||||
- **Director rosters are KIND-INDEXED (index == Kind 0..3), NOT round-robin.** The `WaveDirector` still held the OLD round-robin pool `[Werewolf, WerewolfUndead, Kaiju, ChargerMuscle]` — under MC-2 that silently maps the charger model into the Swarmer slot etc. Always rebuild the roster as `[Grunt,Charger,Spitter,Swarmer]` when adopting `KindForSlot`.
|
||||
- **A `DynamicBuffer` handle is invalidated by any later structural change** — in a test, create the prefab entities BEFORE `AddBuffer`, populate with no `CreateEntity`/`AddComponent` in between (the cluster-test setup bug).
|
||||
- **Reuse rigged variants, don't rebuild rigs.** A plain `AssetDatabase.CopyAsset` duplicate of a working Rukhanka prefab + adding a marker authoring bakes clean and dodges the `EnemyRigTools`/skeleton-rebuild risk entirely.
|
||||
- The fun-gate co-op playtest (Slice 3's too) is still **pending** — the open question is whether the fight is fun, not test counts.
|
||||
+17
-10
@@ -76,17 +76,24 @@ Adopt `WaveSlots`/`KindForSlot` + a 4-entry prefab buffer; **add `WaveDirector.M
|
||||
Pure-math: `WaveSlots`≥1, per-epoch ramps, `KindForSlot` determinism + composition counts, swarmer bucketing, `PackSizeForSlot`≥1, `IsChargerSlot` wrapper keeps 4 legacy assertions; **parity** test (`WaveSlots` reproduces legacy curve); `BandVelocity` (retreat/advance/in-band/Y-flat); `ClusterOffset` determinism. System: `EnemyProjectileMove` integrate+LastStep+range-expiry; `EnemyProjectileDamage` append+at-most-once + **tunnelling regression** (LastStep>radius still hits) + **region-filter** (Expedition spit doesn't damage Base target); Spitter brain (advance/retreat/in-band-commit-then-spawn-with-Region, soft-cap no-burn); cornered fallback; dash-through-spit negation; cluster spawn (PackSize spawned, 1 slot consumed, pack-over-MaxAlive defers); discriminator routing (no double-visit); 4-entry buffer guard. **Play-validation:** no sort-cycle at world-creation; no Burst ICE; Spitter end-to-end (holds range, aim-line telegraph, dodgeable+dash-negatable spit); region correctness (no cross-region see/damage); swarmer reads as a swarm + respects MaxAlive; mix ramp visibly shifts; MC-3 magnitude-scaled punch + predicted tick-rate UNAFFECTED (no timeScale); perf (live spits ≤24, stable frame time under base siege + expedition swarm).
|
||||
|
||||
## Consequences
|
||||
- **Deferred to a later slice:** DOTS `[MaterialProperty]` enemy hit-flash (ShaderGraph `_Flash*` + render-entity mapping); true freeze-frame hit-stop (gated off); Spitter in-band strafe (1b); player-shoots-spit (2b); Swarmer pack-size epoch ramp (field exposed, unwired).
|
||||
- **Deferred to a later slice:** DOTS `[MaterialProperty]` enemy hit-flash (ShaderGraph `_Flash*` + render-entity mapping); true freeze-frame hit-stop (gated off); Spitter in-band strafe (1b); player-shoots-spit (2b); Swarmer pack-size epoch ramp (field exposed, unwired); **per-region** spit soft-cap (v1 is GLOBAL — 24 is generous); a co-op **local-attribution gate** for the player-dealt-hit punch (v1 keys on any enemy-Health edge, so a teammate's hit nudges your camera a touch); **bespoke Spitter/Swarmer art** (v1 reuses the rigged Kaiju + Undead-Werewolf models for distinct silhouettes).
|
||||
- **Open (operator):** the combat fun-gate is a hands-on co-op playtest after build ("play with a friend and not want to stop"); the Slice 3 fun-gate still pending too.
|
||||
- **Status:** reviewed + locked; build IN FLIGHT (see below). Full review (verdicts/blockers/forks) in run transcript `wf_eb115556-8cc`.
|
||||
- **Status:** BUILT + post-impl-reviewed + Play-validated (2026-06-24). Pre-coding review `wf_eb115556-8cc`; post-impl adversarial review `wf_febdcfdb-665` (3 lenses → per-finding refutation; **1 MED + 3 LOW fixed in code, 2 LOW documented**). **388/388 EditMode** + a clean netcode Play smoke (boot, fire, swept-hit, region, server==client). Fun-gate co-op playtest still pending.
|
||||
|
||||
## Build progress (in flight — 2026-06-22)
|
||||
## Build record (complete — 2026-06-24)
|
||||
|
||||
**Done + compiling clean (368/368 EditMode still green, backward-compatible at epoch 1):**
|
||||
- Leaf components: `SpitterState`(+baked `WindupTicks`), `SwarmerTag`, `EnemyProjectile`, `SpitterProjectilePrefab`, `MixBands` (`Simulation/Combat/`).
|
||||
- Math: `EnemyAIMath.{BandVelocity, ClusterOffset}`; `ZoneEnemyMath` Kind consts + `WaveSlots/KindForSlot/PackSizeForSlot` (legacy `WaveSize`/`IsChargerSlot` kept intact for parity).
|
||||
- Systems: `EnemyProjectileMoveSystem` + `EnemyProjectileDamageSystem` (plain server group, LastStep swept, region filter); `EnemyAISystem` Spitter pass + partition guards (`WithNone<LungeState,SpitterState>` Grunt / `WithNone<SpitterState>` Charger) + `m_EnemyProjectiles` cache.
|
||||
- Discriminator: `EnemyTelegraph.IsCharger→byte Kind`; `EnemyAuthoring` bakes Kind from sibling authoring.
|
||||
- Authoring: `SpitterAuthoring`, `SwarmerAuthoring`, `EnemyProjectileAuthoring`; both director components (`ZoneEnemyDirector`, `WaveDirector`) + their authoring gained the mix/cluster fields + (Wave) mandatory `MaxAlive`. Base siege adopts `WaveSlots`/`KindForSlot`/cluster (fork 4a); defaults keep the size curve (≈+1 charger +1 spitter/wave) so the END-game stays bounded.
|
||||
Built through the full `/dots-dev` ladder: **388/388 EditMode green** + a Play smoke (clean two-world boot, no sort-cycle/ICE; Spitter fires → spit sweeps → player damaged → drained, region-correct, **server==client**) + the post-impl adversarial review (`wf_febdcfdb-665`). Two commits: the **MC-2 server spine** (`56cf60cce`) and the **MC-3 + tests + wiring + review-fixes** (one commit, on operator go).
|
||||
|
||||
**Remaining:** MC-3 client juice (FeelConfig fields + `CombatFeedbackSystem` player-hit camera punch + Spitter `Kind==2` aim-line); the additive EditMode tests (per the test plan above); prefab + subscene wiring (Spitter/Swarmer/EnemyProjectile ghosts via the new-ghost recipe, 4-entry director rosters, `SpitterProjectilePrefab` singleton, MixBands/MaxAlive on both directors); then the verify ladder + Play smoke + post-impl review + doc bookend + commit. **Resume from here if compacted.**
|
||||
**Shipped (MC-2 spine):** `SpitterState`, `SwarmerTag`, `EnemyProjectile`, `SpitterProjectilePrefab`, `MixBands`; `EnemyAIMath.{BandVelocity,ClusterOffset}`; `ZoneEnemyMath` Kind consts + `WaveSlots/KindForSlot/PackSizeForSlot` (legacy `WaveSize`/`IsChargerSlot` kept + parity-tested); `EnemyProjectileMove/DamageSystem` (plain server group, LastStep swept, region filter, at-most-once destroy); `EnemyAISystem` Spitter pass + partition guards; `EnemyTelegraph.IsCharger→byte Kind` + `EnemyAuthoring` Kind baking.
|
||||
|
||||
**Shipped (MC-3 juice):** 7 `FeelConfig` fields (+ `ResetDefaults`); magnitude-scaled **player-dealt-hit `PunchFov`** on the enemy-`Health`-decrease edge; the **Spitter `Kind==2` aim-LANE** telegraph (`BuildLaneMesh`; reads baked `SpitterState` client-side, falls back to a fixed length). True freeze-frame + `[MaterialProperty]` enemy hit-flash DEFERRED (gated off / own ShaderGraph slice).
|
||||
|
||||
**Shipped (content):** `SpitterProjectilePrefabAuthoring` (the singleton); both directors carry a 4-entry **kind-indexed** roster `[Grunt,Charger,Spitter,Swarmer]` + mix/MaxAlive config (fork-4a base siege included) + the `SpitterProjectileConfig` singleton. **Real rigged models:** Spitter = `EnemySpitter` (rigged Kaiju, dialed to a ranged poker — HP 28/spd 2.8/cd 66), Swarmer = `EnemySwarmerUndead` (rigged Undead-Werewolf — HP 8/spd 6.5/cd 24, scale 0.6); grunt/charger keep Werewolf/ChargerMuscle. Spit = `EnemySpit` (ownerless interpolated ghost, no `Health`, no collider). All bake clean (zero console errors, Rukhanka rig intact). *(The simple-mesh `Enemy*` prototypes were left untouched; `EnemySwarmer.prefab` reverted.)*
|
||||
|
||||
**Post-impl review fixes (`wf_febdcfdb-665`, all re-validated 388/388 + a re-Play):**
|
||||
- **[MED] in-band fire gate** — the Spitter committed its telegraph from ANY range (would fire while advancing from far, defeating the hold-range question). Fixed: commit only when `sInBand || sCornered` (which gives the once-dead `CorneredRange` a real read site — a Spitter advancing-from-far OR retreating-from-too-close now holds fire and repositions). Guarded by new `Spitter_OutOfBand_DoesNotCommitWindup` + `Spitter_Cornered_CommitsWindupPointBlank`.
|
||||
- **[LOW] `EnemyProjectileDamageSystem`** now early-returns on `!ServerTick.IsValid` (sibling-system parity).
|
||||
- **[LOW] bake-time Charger+Spitter guard** — `EnemyAuthoring.Bake` `Debug.LogError`s a prefab composing BOTH (it would match zero AI passes → never move).
|
||||
- **[LOW] test quality** — the Spitter brain test now fires from **Expedition** (a dropped `Region` copy = 0 = Base would fail it, killing the `Base==0` false-green); the overstated "proves the partition" claim is replaced by a **direct** partition-exclusion test (`Spitter_IsExcludedFromGruntAndChargerPasses`).
|
||||
|
||||
**Accepted (documented, not defects):** the `MaxLiveProjectiles` soft-cap is GLOBAL across regions (24 generous; per-region deferred); the player-dealt-hit punch keys on the enemy-`Health` edge per the locked design (a teammate's hit nudges your camera a touch in co-op — a future local-attribution gate). See the Deferred list above.
|
||||
|
||||
Reference in New Issue
Block a user