3109b86d71
Reactivate the dormant Expedition region as a procedural combat venue.
v1 loop: walk the gate -> fight an epoch-seeded enemy wave in the
expedition -> clear -> return -> flat Ore reward (once per epoch) ->
escalated retaliation base siege.
- New sim types: ZoneEnemyTag, ZoneEnemyDirector (+ ZoneEnemyPrefab
buffer), ZoneEnemyState, ZoneEnemyMath (grunt->charger composition
by epoch). ZoneEnemyDirectorSystem (server, Burst): drip-spawns the
wave at a deterministic ring under a MaxAlive cap while a player is
out and the base is Calm; marks ClearedThisEpoch on a real clear.
[UpdateAfter(ExpeditionFieldSystem)] only (avoids a sort cycle).
- BLOCKER 1: EnemyAISystem region-filters target selection (player +
structure snapshots gain parallel region lists; no base structures /
no Core fallback for expedition husks).
- BLOCKER 3: WaveSystem, ThreatDirectorSystem timeout cull, and
CyclePhaseSystem DefendCleared + Core-breach cull all count/cull
RegionTag{Base} husks only (the breach cull was caught region-blind
by the post-impl review: a base breach wiped the live expedition
wave and spuriously paid the reward).
- BLOCKER 4: reward de-duped via CycleRuntime.LastRewardedEpoch +
ClearedThisEpoch; ExpeditionGateSystem deposits RewardOre once/epoch.
- ExpeditionFieldSystem teardown also culls zone enemies + region-
guards the clutter loop. Subscene wired with the director + roster.
368/368 EditMode green + clean netcode Play smoke. Docs: DR-040 ->
built, session log, CLAUDE.md cross-region tag-reaudit rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
7.1 KiB
C#
135 lines
7.1 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;
|
|
|
|
// (Re)seed this epoch's wave once — its OWN counter, never WaveState's.
|
|
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
|
|
}
|
|
|
|
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.
|
|
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))
|
|
{
|
|
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;
|
|
|
|
float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
|
|
pos.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();
|
|
|
|
zs.SpawnCounter += 1;
|
|
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
|
|
// once-per-epoch Ore reward on the player's return to base.
|
|
runtime.ClearedThisEpoch = 1;
|
|
SystemAPI.SetComponent(cycleEntity, runtime);
|
|
}
|
|
|
|
SystemAPI.SetComponent(directorEntity, zs);
|
|
}
|
|
}
|
|
}
|