56cf60cce3
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>
159 lines
8.4 KiB
C#
159 lines
8.4 KiB
C#
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>
|
|
/// Server-only expedition zone-enemy director: while a player is OUT in the expedition region and the base is in
|
|
/// <see cref="CyclePhase.Calm"/>, it seeds and drip-spawns one epoch-seeded combat wave around the expedition
|
|
/// origin. The wave size + grunt/charger composition is pure <see cref="ZoneEnemyMath"/> of the
|
|
/// <see cref="CycleRuntime.ExpeditionEpoch"/> (grunt-heavy -> charger-heavy as the epoch climbs), spawned one
|
|
/// every <see cref="ZoneEnemyDirector.SpawnIntervalTicks"/> at a deterministic ring, capped at
|
|
/// <see cref="ZoneEnemyDirector.MaxAlive"/> concurrent (the v1 ghost-relevancy budget). Each enemy is the
|
|
/// existing Husk ghost prefab + <c>RegionTag{Expedition}</c> + <see cref="ZoneEnemyTag"/>, so it reuses the whole
|
|
/// combat/readability/AI stack (the per-region AI filter keeps it seeking the expedition player only). When the
|
|
/// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once —
|
|
/// the gate's once-per-epoch Ore reward reads that on the player's return.
|
|
///
|
|
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
|
|
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the
|
|
/// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play
|
|
/// world creation and is invisible to EditMode. Running after the field manager also reads the freshly-bumped
|
|
/// epoch + current phase. Zone enemies are interpolated ghosts, moved server-only — no prediction.
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
[UpdateAfter(typeof(ExpeditionFieldSystem))]
|
|
public partial struct ZoneEnemyDirectorSystem : ISystem
|
|
{
|
|
EntityQuery m_ZoneEnemies;
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<CycleState>();
|
|
state.RequireForUpdate<ZoneEnemyDirector>();
|
|
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
|
if (!serverTick.IsValid)
|
|
return;
|
|
uint now = serverTick.TickIndexForValidTick;
|
|
|
|
// Per-player presence: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem).
|
|
int expeditionPlayers = 0;
|
|
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
|
|
if (region.ValueRO.Region == RegionId.Expedition)
|
|
expeditionPlayers++;
|
|
if (expeditionPlayers == 0)
|
|
return; // nobody out there: the field manager owns teardown, we do nothing
|
|
|
|
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
|
|
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
|
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
|
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
|
if (prefabs.Length == 0)
|
|
return;
|
|
|
|
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
|
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
|
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
|
int epoch = runtime.ExpeditionEpoch;
|
|
|
|
// 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.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), 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)
|
|
{
|
|
int slot = (int)zs.SpawnCounter;
|
|
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
|
|
int packSize = kind == ZoneEnemyMath.KindSwarmer
|
|
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
|
|
|
|
// 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;
|
|
|
|
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);
|
|
|
|
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. 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);
|
|
}
|
|
|
|
SystemAPI.SetComponent(directorEntity, zs);
|
|
}
|
|
}
|
|
}
|