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
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only
|
||||
/// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the
|
||||
/// plain <see cref="SimulationSystemGroup"/> after <see cref="EnemyProjectileMoveSystem"/> (post-move position).
|
||||
/// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED
|
||||
/// <see cref="EnemyProjectile.LastStep"/> (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a
|
||||
/// target whose <see cref="RegionTag"/>.Region != the spit's Region is skipped — relevancy hides cross-region
|
||||
/// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs
|
||||
/// its own guard (the missing-filter blocker the design review caught). On a hit it appends
|
||||
/// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted
|
||||
/// <c>HealthApplyDamageSystem</c> — appending from the predicted loop would double-apply on rollback; SourceTick
|
||||
/// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and
|
||||
/// destroys the spit at-most-once; a spit past its Range expires.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyProjectileMoveSystem))]
|
||||
public partial struct EnemyProjectileDamageSystem : ISystem
|
||||
{
|
||||
/// <summary>Extra forgiveness for the spit's own size, added to a target's hit radius.</summary>
|
||||
const float k_ProjectileRadius = 0.2f;
|
||||
|
||||
/// <summary>Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them).</summary>
|
||||
const float k_StructureRadius = 1.0f;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<EnemyProjectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring);
|
||||
// STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant.
|
||||
var targetEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var targetPositions = new NativeList<float3>(Allocator.Temp);
|
||||
var targetRadii = new NativeList<float>(Allocator.Temp);
|
||||
var targetRegions = new NativeList<byte>(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, hitRadius, health, region, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlayerTag>().WithEntityAccess())
|
||||
{
|
||||
if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse
|
||||
targetEntities.Add(e);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(hitRadius.ValueRO.Value);
|
||||
targetRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
foreach (var (xform, health, region, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlacedStructure>().WithEntityAccess())
|
||||
{
|
||||
if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick
|
||||
targetEntities.Add(e);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(k_StructureRadius);
|
||||
targetRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
|
||||
var destroyed = new NativeHashSet<Entity>(16, Allocator.Temp);
|
||||
foreach (var (xform, proj, projEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyProjectile>>().WithEntityAccess())
|
||||
{
|
||||
float3 cur = xform.ValueRO.Position;
|
||||
float2 segEnd = new float2(cur.x, cur.z);
|
||||
float2 dir = proj.ValueRO.Direction;
|
||||
float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt
|
||||
float2 seg = segEnd - segStart;
|
||||
float segLenSq = math.lengthsq(seg);
|
||||
byte projRegion = proj.ValueRO.Region;
|
||||
|
||||
int bestIdx = -1;
|
||||
float bestT = float.MaxValue;
|
||||
for (int i = 0; i < targetEntities.Length; i++)
|
||||
{
|
||||
if (targetRegions[i] != projRegion) continue; // server-side damage region guard
|
||||
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
||||
float t = segLenSq > 1e-8f
|
||||
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
|
||||
: 0f;
|
||||
float2 closest = segStart + t * seg;
|
||||
float hitDist = targetRadii[i] + k_ProjectileRadius;
|
||||
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx >= 0)
|
||||
{
|
||||
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
|
||||
{
|
||||
Amount = proj.ValueRO.Damage,
|
||||
SourceNetworkId = -1, // hostile environment, not a player
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
if (destroyed.Add(projEntity))
|
||||
ecb.DestroyEntity(projEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity))
|
||||
ecb.DestroyEntity(projEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
ecb.Dispose();
|
||||
destroyed.Dispose();
|
||||
targetEntities.Dispose();
|
||||
targetPositions.Dispose();
|
||||
targetRadii.Dispose();
|
||||
targetRegions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f6dbd4ab9a2b154e8d7cb1796904ab6
|
||||
@@ -0,0 +1,49 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — integrates hostile Spitter projectiles (<see cref="EnemyProjectile"/>) server-only in the plain
|
||||
/// <see cref="SimulationSystemGroup"/> (the spits are ownerless INTERPOLATED ghosts, not predicted — like the
|
||||
/// Husks that fire them). Advances each spit along its locked Direction at Speed*dt, accumulates
|
||||
/// DistanceTravelled, and STORES <see cref="EnemyProjectile.LastStep"/> = Speed*dt so
|
||||
/// <see cref="EnemyProjectileDamageSystem"/> can rebuild the exact swept segment it traversed this tick
|
||||
/// (cur - Direction*LastStep) WITHOUT re-reading a delta in that separate system (the DR-018 swept-tunnelling
|
||||
/// discipline — a fresh delta in the damage pass is the trap). Ordered <c>[UpdateAfter(EnemyAISystem)]</c> (the
|
||||
/// spawner) so a spit moves the same tick it is born. Writes LocalTransform (replicated via the stock variant);
|
||||
/// structural-free. dt is the server fixed step here, exactly as <see cref="EnemyAISystem"/> reads it.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyAISystem))]
|
||||
public partial struct EnemyProjectileMoveSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<EnemyProjectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
float dt = SystemAPI.Time.DeltaTime; // server fixed step in the plain group, same as EnemyAISystem
|
||||
foreach (var (xform, proj) in SystemAPI.Query<RefRW<LocalTransform>, RefRW<EnemyProjectile>>())
|
||||
{
|
||||
float step = proj.ValueRO.Speed * dt;
|
||||
float3 dir = new float3(proj.ValueRO.Direction.x, 0f, proj.ValueRO.Direction.y);
|
||||
float3 from = xform.ValueRO.Position;
|
||||
float3 pos = from + dir * step;
|
||||
pos.y = from.y; // hold the movement plane
|
||||
xform.ValueRW.Position = pos;
|
||||
proj.ValueRW.LastStep = step;
|
||||
proj.ValueRW.DistanceTravelled = proj.ValueRO.DistanceTravelled + step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9acb4c22874b1fa489433644b90334db
|
||||
@@ -51,6 +51,21 @@ namespace ProjectM.Server
|
||||
|
||||
var wave = SystemAPI.GetComponent<WaveState>(directorEntity);
|
||||
|
||||
// MC-2 fork-4a: the base siege adopts the 4-type weighted mix (BaseCount = the Grunt base). The size
|
||||
// curve becomes WaveSlots(wave, bands) — a deliberate, operator-approved redefinition; MaxAlive is the
|
||||
// mandatory cap so spitter spits + swarmer packs can't spike the relevancy loop during the END-game climax.
|
||||
var bands = new MixBands
|
||||
{
|
||||
GruntBase = director.BaseCount,
|
||||
ChargerBase = director.ChargerBase,
|
||||
SpitterBase = director.SpitterBase,
|
||||
SwarmerSlotBase = director.SwarmerSlotBase,
|
||||
ChargerPerEpoch = director.ChargerPerEpoch,
|
||||
SpitterPerEpoch = director.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = director.SwarmerSlotPerEpoch,
|
||||
SwarmerPackPerEpoch = director.SwarmerPackPerEpoch,
|
||||
};
|
||||
|
||||
// Ring centre on the base plot when present.
|
||||
float3 center = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
|
||||
@@ -65,8 +80,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
// Start the next (bigger) wave.
|
||||
wave.WaveNumber += 1;
|
||||
wave.RemainingToSpawn =
|
||||
math.max(1, director.BaseCount + (wave.WaveNumber - 1) * director.CountPerWave);
|
||||
wave.RemainingToSpawn = ZoneEnemyMath.WaveSlots(wave.WaveNumber, bands);
|
||||
wave.Phase = WavePhase.Spawning;
|
||||
wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
|
||||
}
|
||||
@@ -78,24 +92,41 @@ namespace ProjectM.Server
|
||||
if (dueNow)
|
||||
{
|
||||
int slots = math.max(1, director.RingSlots);
|
||||
int prefabIdx = wave.SpawnCounter % prefabs.Length;
|
||||
float3 pos = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
|
||||
pos.y = center.y;
|
||||
byte kind = ZoneEnemyMath.KindForSlot(wave.WaveNumber, wave.SpawnCounter, bands);
|
||||
int packSize = kind == ZoneEnemyMath.KindSwarmer
|
||||
? ZoneEnemyMath.PackSizeForSlot(wave.WaveNumber, wave.SpawnCounter, bands, director.SwarmerPackSize) : 1;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
|
||||
// Preserve the prefab's baked variant Scale (a replicated [GhostField]) + rotation;
|
||||
// LocalTransform.FromPosition() would reset Scale->1, shrinking/growing animated variants.
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
|
||||
ecb.SetComponent(husk, baked.WithPosition(pos));
|
||||
// Husks belong to the base region (hidden from expedition players by relevancy).
|
||||
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
|
||||
int aliveBase = 0;
|
||||
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
|
||||
if (hr.ValueRO.Region == RegionId.Base) aliveBase++;
|
||||
|
||||
wave.SpawnCounter += 1;
|
||||
wave.RemainingToSpawn -= 1;
|
||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
|
||||
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
|
||||
if (aliveBase + packSize <= math.max(1, director.MaxAlive))
|
||||
{
|
||||
int prefabIdx = kind;
|
||||
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
|
||||
float3 packCenter = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
|
||||
packCenter.y = center.y;
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int k = 0; k < packSize; k++)
|
||||
{
|
||||
float3 pos = packSize > 1
|
||||
? EnemyAIMath.ClusterOffset(packCenter, k, packSize, director.ClusterTightRadius) : packCenter;
|
||||
pos.y = center.y;
|
||||
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
|
||||
ecb.SetComponent(husk, baked.WithPosition(pos)); // preserve baked [GhostField] Scale
|
||||
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
wave.SpawnCounter += 1; // ONE slot consumed even for a pack
|
||||
wave.RemainingToSpawn -= 1;
|
||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -71,58 +71,82 @@ namespace ProjectM.Server
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
int epoch = runtime.ExpeditionEpoch;
|
||||
|
||||
// (Re)seed this epoch's wave once — its OWN counter, never WaveState's.
|
||||
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
|
||||
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
|
||||
var bands = new MixBands
|
||||
{
|
||||
GruntBase = dir.GruntsPerWave,
|
||||
ChargerBase = dir.ChargersPerWave,
|
||||
SpitterBase = dir.SpitterBase,
|
||||
SwarmerSlotBase = dir.SwarmerSlotBase,
|
||||
ChargerPerEpoch = dir.ChargerPerEpoch,
|
||||
SpitterPerEpoch = dir.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
|
||||
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
|
||||
};
|
||||
|
||||
// (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack).
|
||||
if (zs.SeededEpoch != epoch)
|
||||
{
|
||||
zs.SeededEpoch = epoch;
|
||||
zs.SpawnCounter = 0;
|
||||
zs.RemainingToSpawn = ZoneEnemyMath.WaveSize(epoch, dir.GruntsPerWave, dir.ChargersPerWave);
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now); // first enemy this tick
|
||||
zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands);
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
|
||||
}
|
||||
|
||||
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
||||
|
||||
if (zs.RemainingToSpawn > 0)
|
||||
{
|
||||
// Spawn only in Calm (a base Siege pauses the expedition wave; it resumes when the base is safe), one
|
||||
// per cadence, and only while under the concurrent cap.
|
||||
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
|
||||
bool calm = cycle.Phase == CyclePhase.Calm;
|
||||
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
|
||||
if (calm && dueNow && aliveZone < math.max(1, dir.MaxAlive))
|
||||
if (calm && dueNow)
|
||||
{
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
|
||||
|
||||
int slot = (int)zs.SpawnCounter;
|
||||
bool charger = ZoneEnemyMath.IsChargerSlot(epoch, slot, dir.GruntsPerWave, dir.ChargersPerWave);
|
||||
int prefabIdx = charger ? 1 : 0;
|
||||
if (prefabIdx >= prefabs.Length) prefabIdx = prefabs.Length - 1;
|
||||
var prefab = prefabs[prefabIdx].Prefab;
|
||||
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
|
||||
int packSize = kind == ZoneEnemyMath.KindSwarmer
|
||||
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
|
||||
|
||||
float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
|
||||
pos.y = origin.y;
|
||||
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
|
||||
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
|
||||
{
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
|
||||
float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
|
||||
center.y = origin.y;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
var enemy = ecb.Instantiate(prefab);
|
||||
// Preserve the prefab's baked Scale ([GhostField]) + rotation — FromPosition would reset Scale->1.
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
|
||||
ecb.SetComponent(enemy, baked.WithPosition(pos));
|
||||
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
|
||||
ecb.AddComponent<ZoneEnemyTag>(enemy);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
int prefabIdx = kind;
|
||||
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
|
||||
var prefab = prefabs[prefabIdx].Prefab;
|
||||
// Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1.
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
|
||||
|
||||
zs.SpawnCounter += 1;
|
||||
zs.RemainingToSpawn -= 1;
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int k = 0; k < packSize; k++)
|
||||
{
|
||||
float3 pos = packSize > 1
|
||||
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
|
||||
pos.y = origin.y;
|
||||
var enemy = ecb.Instantiate(prefab);
|
||||
ecb.SetComponent(enemy, baked.WithPosition(pos));
|
||||
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
|
||||
ecb.AddComponent<ZoneEnemyTag>(enemy);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
|
||||
zs.RemainingToSpawn -= 1;
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0)
|
||||
{
|
||||
// Wave fully spawned AND every zone enemy dead -> a REAL clear (the player killed them; the empty-edge
|
||||
// teardown can't reach here because we early-return when no one is out). Mark once; the gate pays the
|
||||
// Wave fully spawned AND every zone enemy dead -> a REAL clear. Mark once; the gate pays the
|
||||
// once-per-epoch Ore reward on the player's return to base.
|
||||
runtime.ClearedThisEpoch = 1;
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
|
||||
Reference in New Issue
Block a user