419debad74
Scoping/design-gated (wf_7c5a555e-136). Fixes "the base reads as inert after Phase A":
- C7b objective readout: new replicated ExpeditionObjective{[GhostField] byte State, short Remaining} on the
untagged CycleDirector ghost (cross-region safe). Sole writer ZoneEnemyDirectorSystem, written ABOVE its
early-returns (snapshot-above-early-return) so the HUD never freezes stale. Play-verified it replicates
server->client.
- C7a gate prompt + C7b HUD readout: HudSystem shows "GO TO THE EXPEDITION GATE" / "EXPEDITION IN PROGRESS - N
remaining" / "CLEARED - return to claim", below the siege/overrun overrides.
- C6a Aether upgrade button: un-gated BuildSendSystem.UpgradeAbility (was #if UNITY_EDITOR); HudSystem adds a
MenuUi.Button with live affordability tint (the only Aether sink was U-key only).
- C6c cold-start seed: CycleDirectorSpawnSystem seeds Tuning.StartingOre (50) into the ledger on a NEW game only
(born-correct, pre-Playback), killing the silent turret-before-fabricator deadlock. Play-verified seededOre=50.
- C6b Biomass sink: Wall cost Ore->Biomass (the dead currency now has a home). Play-verified WallCostRes=Biomass.
- C6d palette declutter: hide dead Pylon/Harvester/Conveyor from the build palette + trimmed their dev hotkeys
(catalog/prefabs stay baked, code-intact per DR-020).
389/389 EditMode + clean netcode Play smoke (ghost re-hash OK, no exceptions). SaveData stays v5.
C5 (walls block enemies) is the remaining Phase C item, sequenced separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
190 lines
10 KiB
C#
190 lines
10 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.
|
|
///
|
|
/// DR-042 C7b: it ALSO writes the replicated <see cref="ExpeditionObjective"/> summary every tick, ABOVE the
|
|
/// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout
|
|
/// never freezes stale even when nobody is out.
|
|
///
|
|
/// 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: the SPAWNER only runs while someone is OUT in the expedition (mirrors
|
|
// ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out.
|
|
int expeditionPlayers = 0;
|
|
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
|
|
if (region.ValueRO.Region == RegionId.Expedition)
|
|
expeditionPlayers++;
|
|
|
|
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
|
|
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
|
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
|
|
|
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
|
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
|
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
|
int epoch = runtime.ExpeditionEpoch;
|
|
|
|
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
|
|
|
// DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above-
|
|
// early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe).
|
|
if (SystemAPI.HasComponent<ExpeditionObjective>(cycleEntity))
|
|
{
|
|
byte objState;
|
|
short objRemaining;
|
|
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
|
{
|
|
objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim"
|
|
objRemaining = 0;
|
|
}
|
|
else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0))
|
|
{
|
|
objState = ExpeditionObjectiveState.Active;
|
|
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
|
|
}
|
|
else
|
|
{
|
|
objState = ExpeditionObjectiveState.Idle;
|
|
objRemaining = 0;
|
|
}
|
|
SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
|
|
}
|
|
|
|
if (expeditionPlayers == 0)
|
|
return; // nobody out there: the field manager owns teardown, the spawner does nothing
|
|
|
|
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
|
if (prefabs.Length == 0)
|
|
return;
|
|
|
|
// 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
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|