Slice Combat Depth (MC-2): enemy-variety server spine — Spitter, Swarmer, 4-type mix (DR-041)
Adds the server-authoritative mechanics for three new enemy archetypes on top of the Grunt/Charger base, plus the weighted wave-composition that introduces them: - Spitter: a ranged Husk variant (SpitterState) that holds a preferred range-band (advance/retreat/hold via EnemyAIMath.BandVelocity) and fires a telegraphed, dodgeable EnemyProjectile. New server EnemyProjectileMoveSystem (integrate + store LastStep) + EnemyProjectileDamageSystem (region-filtered swept hit-test rebuilt from LastStep — DR-018 anti-tunnelling; players use HitRadius, structures a const radius; at-most-once destroy). Concurrent-spit soft cap, soft-fail retry. - Swarmer: marker tag + deterministic cluster spawn (1 slot = 1 pack; EnemyAIMath.ClusterOffset), MaxAlive counts ENTITIES so a pack defers if it won't fit. - 4-type weighted mix: MixBands -> ZoneEnemyMath.WaveSlots/KindForSlot/ PackSizeForSlot drives both the expedition director and (fork-4a) the base siege, with a mandatory MaxAlive cap. Legacy WaveSize/IsChargerSlot kept + parity-tested. - Discriminator stays component-presence (no enum in Bursted systems): query- partition guards keep each enemy moved by exactly one EnemyAISystem pass (sole-Position-writer). EnemyTelegraph.IsCharger -> Kind byte for the client cue. New authoring (Spitter/Swarmer/EnemyProjectile) + expanded director authorings with tunable mix/cluster defaults. 13 new EditMode tests (mix composition + legacy parity, band/cluster math, projectile move + cross-region + swept anti-tunnelling regressions); full suite green before commit. Dormant until the prefab/subscene wiring lands (next): the new systems guard on TryGetSingleton/RequireForUpdate, so with no prefabs wired the new types stay inert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,11 +27,14 @@ namespace ProjectM.Server
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
public partial struct EnemyAISystem : ISystem
|
||||
{
|
||||
EntityQuery m_EnemyProjectiles;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
|
||||
m_EnemyProjectiles = state.GetEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -104,7 +107,7 @@ namespace ProjectM.Server
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>().WithNone<LungeState>())
|
||||
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte huskRegion = region.ValueRO.Region;
|
||||
@@ -212,7 +215,7 @@ namespace ProjectM.Server
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>())
|
||||
.WithAll<EnemyTag>().WithNone<SpitterState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte cHuskRegion = region.ValueRO.Region;
|
||||
@@ -331,6 +334,112 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Spitter pass: a Husk variant baked with SpitterState holds a RANGED range-band and fires a
|
||||
// telegraphed, dodgeable spit. Partitioned .WithAll<SpitterState>().WithNone<LungeState>() (and the Grunt
|
||||
// pass excludes SpitterState) so a Spitter is moved by EXACTLY this pass — the sole-Position-writer rule.
|
||||
bool haveSpit = SystemAPI.TryGetSingleton<SpitterProjectilePrefab>(out var spitCfg) && spitCfg.Prefab != Entity.Null;
|
||||
int liveSpits = m_EnemyProjectiles.CalculateEntityCount();
|
||||
LocalTransform spitBakedLt = default;
|
||||
EnemyProjectile spitBakedProj = default;
|
||||
if (haveSpit)
|
||||
{
|
||||
spitBakedLt = state.EntityManager.GetComponentData<LocalTransform>(spitCfg.Prefab);
|
||||
spitBakedProj = state.EntityManager.GetComponentData<EnemyProjectile>(spitCfg.Prefab);
|
||||
}
|
||||
foreach (var (xform, stats, knockback, windup, spitter, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
|
||||
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte sRegion = region.ValueRO.Region;
|
||||
bool sCoreAlive = coreAlive && sRegion == RegionId.Base;
|
||||
|
||||
// 1. Knockback overrides everything (sole Position writer preserved).
|
||||
var kb = knockback.ValueRO;
|
||||
if (kb.UntilTick != 0)
|
||||
{
|
||||
var kbTick = new NetworkTick(kb.UntilTick);
|
||||
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
|
||||
kpos.y = pos.y;
|
||||
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = kpos;
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
continue;
|
||||
}
|
||||
knockback.ValueRW.UntilTick = 0;
|
||||
}
|
||||
|
||||
// 2. Target (region-scoped shared helper); Core fallback like the Grunt/Charger passes.
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx);
|
||||
if (sIdx < 0 && !sCoreAlive)
|
||||
continue;
|
||||
Entity sTargetEntity = sIdx < 0 ? Entity.Null
|
||||
: (sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]);
|
||||
float3 sTargetPos = sIdx < 0 ? corePos
|
||||
: (sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]);
|
||||
|
||||
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
|
||||
var sp = spitter.ValueRO;
|
||||
float3 bandVel = EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
|
||||
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
|
||||
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = sNewPos;
|
||||
float3 sToTarget = sTargetPos - pos; sToTarget.y = 0f;
|
||||
if (math.lengthsq(sToTarget) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(sToTarget), math.up());
|
||||
|
||||
// 4. Telegraphed shot: commit a wind-up (the dodge window) when the shot gate is ready; on elapse,
|
||||
// spawn a spit toward the target. A cornered Spitter still fires (point-blank) — no safe corner.
|
||||
uint sWindRaw = windup.ValueRO.WindUpUntilTick;
|
||||
if (sWindRaw != 0)
|
||||
{
|
||||
var sWindTick = new NetworkTick(sWindRaw);
|
||||
if (!(sWindTick.IsValid && sWindTick.IsNewerThan(serverTick)))
|
||||
{
|
||||
float2 dir2 = math.lengthsq(sToTarget) > 1e-6f ? math.normalize(sToTarget.xz) : new float2(0f, 1f);
|
||||
if (haveSpit && liveSpits < math.max(1, spitCfg.MaxLiveProjectiles))
|
||||
{
|
||||
float3 spawnPos = pos + new float3(dir2.x, 0f, dir2.y) * 0.8f;
|
||||
spawnPos.y = pos.y;
|
||||
var spit = ecb.Instantiate(spitCfg.Prefab);
|
||||
ecb.SetComponent(spit, spitBakedLt.WithPosition(spawnPos)); // preserve baked [GhostField] Scale
|
||||
ecb.SetComponent(spit, new EnemyProjectile
|
||||
{
|
||||
Direction = dir2,
|
||||
Speed = sp.ProjectileSpeed,
|
||||
Damage = stats.ValueRO.AttackDamage,
|
||||
Range = spitBakedProj.Range,
|
||||
DistanceTravelled = 0f,
|
||||
LastStep = 0f,
|
||||
Region = sRegion,
|
||||
});
|
||||
ecb.AddComponent(spit, new RegionTag { Region = sRegion }); // relevancy (the spit prefab bakes none)
|
||||
liveSpits++;
|
||||
uint shotCd = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + shotCd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Over the concurrent cap (or no prefab wired): soft-fail — short retry, no full cooldown burn.
|
||||
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + 8u);
|
||||
}
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick);
|
||||
if (sReady && (sTargetEntity != Entity.Null || sCoreAlive))
|
||||
{
|
||||
uint wTicks = (uint)math.max(1, sp.WindupTicks);
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState
|
||||
// (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a
|
||||
|
||||
Reference in New Issue
Block a user