ea108e48e4
B1 SEPARATION: pairwise soft-collision pass INSIDE EnemyAISystem (sole enemy-Position writer preserved; O(n^2) fine at MaxAlive<=14). Knocked/ lunging enemies keep committed motion but still push neighbours; the boss is never pushed; enemies yield a small personal radius around players (below melee range per review B1-1); displacement goes through the swept move so nothing shoves through walls. SeparationMaxSpeed = live knob. B2 POISE: the windup-cancel lived in the knockback CONTINUE branches (grunt/ charger/spitter). Threshold on the EXISTING KnockbackState.Speed channel: light melee (6) nudges without interrupting; the finisher (10.8) and cone (8) stagger as before. StaggerKnockbackSpeed = live knob (default 7). B4 BOSS LUNGE: a telegraphed gap-closer on its own cooldown when the target sits outside slam reach (repositioning - the slam stays the damage beat). BossState.PendingAttack (server-only byte) disambiguates the shared windup elapse (review-confirmed: naive reuse would SLAM on a lunge elapse); LungeState.UntilTick spans windup+travel so the existing IsLunging ghost bit replicates the tell, and the client draws a travel wedge instead of the slam ring while it is set. B5 PARTY HP SCALE: boss Health x(1 + 0.75/extra LIVING expedition player) at spawn (not RunParticipant - dead-respawned members park at base). Max is a GhostField so the bar stays truthful. B6 RUN-FAILED BANNER: silent wipes used to land players home with zero explanation. Client detects the (in-run)->Staging edge with a launch-cached Charge (NEVER Returning - a 1-tick transient that precedes the bank by a tick) and shows EXPEDITION FAILED for 6 s. B7 FIRE ANIM: the class ability finally moves the body - a stateless window from replicated AbilityCooldown.NextFireTick minus derived CooldownTicks (works for predicted local + interpolated remotes, no cached edges). 456/456 EditMode; live Play smoke: launch -> 5 kills credit the room clear through the corpse window -> boon window opens -> corpses expire clean, no exceptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
215 lines
12 KiB
C#
215 lines
12 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 per-ROOM enemy director — the Step-6 successor of the presence-keyed <c>ZoneEnemyDirectorSystem</c>.
|
||
/// While the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) it seeds ONE wave per
|
||
/// <see cref="RunRuntime.RoomEpoch"/> (int-equality reseed) sized by <see cref="ZoneEnemyMath.WaveSlots"/> indexed
|
||
/// on the room's <see cref="RoomPlan.DifficultyEpoch"/> (deeper rooms + Elite/Boss types skew heavier — the
|
||
/// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring
|
||
/// around <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot), under the same
|
||
/// <see cref="ZoneEnemyDirector.MaxAlive"/> "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A
|
||
/// <see cref="RoomTypeId.Boss"/> room spawns ONE beefed boss instead (health × <see cref="Tuning.BossHealthMultiplier"/>,
|
||
/// scale × <see cref="Tuning.BossScaleMultiplier"/> — v1's boss is a scaled Charger). Every spawn keeps the full
|
||
/// stack — EnemyTag + RegionTag{Expedition} + <see cref="ZoneEnemyTag"/> — PLUS <see cref="RoomTag"/>{room} (the
|
||
/// teardown contract). Scale preserved via <c>baked.WithPosition</c>.
|
||
///
|
||
/// The room CLEAR edge surfaces ONLY through the replicated <see cref="ExpeditionObjective"/>.State == Cleared
|
||
/// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return
|
||
/// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7).
|
||
/// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is
|
||
/// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap).
|
||
///
|
||
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> ONLY — reads the freshly-advanced room state same-tick.
|
||
/// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode).
|
||
/// </summary>
|
||
[BurstCompile]
|
||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||
[UpdateAfter(typeof(RunDirectorSystem))]
|
||
public partial struct RoomEnemyDirectorSystem : ISystem
|
||
{
|
||
EntityQuery m_ZoneEnemies;
|
||
|
||
[BurstCompile]
|
||
public void OnCreate(ref SystemState state)
|
||
{
|
||
state.RequireForUpdate<NetworkTime>();
|
||
state.RequireForUpdate<RunInfo>();
|
||
state.RequireForUpdate<RunRuntime>();
|
||
state.RequireForUpdate<ZoneEnemyDirector>();
|
||
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns
|
||
}
|
||
|
||
[BurstCompile]
|
||
public void OnUpdate(ref SystemState state)
|
||
{
|
||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||
if (!serverTick.IsValid)
|
||
return;
|
||
uint now = serverTick.TickIndexForValidTick;
|
||
|
||
var runEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||
var info = SystemAPI.GetComponent<RunInfo>(runEntity);
|
||
var run = SystemAPI.GetComponent<RunRuntime>(runEntity);
|
||
bool roomActive = info.Lifecycle == RunLifecycle.InRoom;
|
||
|
||
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
|
||
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
||
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
||
|
||
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
||
|
||
// REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD
|
||
// readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch.
|
||
if (SystemAPI.HasComponent<ExpeditionObjective>(runEntity))
|
||
{
|
||
byte objState;
|
||
short objRemaining;
|
||
if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0))
|
||
{
|
||
objState = ExpeditionObjectiveState.Active;
|
||
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
|
||
}
|
||
else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0)
|
||
{
|
||
objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready
|
||
objRemaining = 0;
|
||
}
|
||
else
|
||
{
|
||
objState = ExpeditionObjectiveState.Idle;
|
||
objRemaining = 0;
|
||
}
|
||
SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
|
||
}
|
||
|
||
if (!roomActive)
|
||
return;
|
||
|
||
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
||
if (prefabs.Length == 0)
|
||
return;
|
||
|
||
// Single plan authority: the node RunDirector published — never re-derived here.
|
||
var map = RunMapMath.Generate(run.RunSeed);
|
||
var node = map.NodeAt(run.CurrentNodeId);
|
||
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
|
||
byte room = (byte)(info.CurrentRoom & 0xFF);
|
||
bool bossRoom = plan.RoomType == RoomTypeId.Boss;
|
||
|
||
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 once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot).
|
||
if (zs.SeededEpoch != run.RoomEpoch)
|
||
{
|
||
zs.SeededEpoch = run.RoomEpoch;
|
||
zs.SpawnCounter = 0;
|
||
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
|
||
zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient
|
||
}
|
||
|
||
if (zs.RemainingToSpawn > 0)
|
||
{
|
||
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
|
||
if (dueNow)
|
||
{
|
||
int slot = (int)zs.SpawnCounter;
|
||
byte kind = bossRoom ? ZoneEnemyMath.KindCharger
|
||
: ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands);
|
||
int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer
|
||
? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1;
|
||
|
||
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep 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.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
||
float3 center = bossRoom
|
||
? origin + new float3(0f, 0f, 12f) // the boss anchors the room 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;
|
||
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 = ZoneEnemySpawnUtil.Spawn(ecb, prefab, in baked, pos, RegionId.Expedition, room);
|
||
if (bossRoom)
|
||
{
|
||
// Boss = a scaled Charger given a real kit by BossAISystem. Scale the visual AND the
|
||
// hitbox/reach (so hits register on the big model + its reach matches), multiply Health,
|
||
// and tag BossState (server-only discriminator) so BossAISystem alone drives it.
|
||
var bxform = baked.WithPosition(pos);
|
||
bxform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
|
||
ecb.SetComponent(enemy, bxform);
|
||
if (SystemAPI.HasComponent<Health>(prefab))
|
||
{
|
||
// B5: party-size HP scaling by LIVING EXPEDITION players at spawn (NOT the
|
||
// RunParticipant count - dead-respawned members keep the tag while parked at
|
||
// base). Health.Max is a [GhostField] since DR-046, so the scaled max replicates.
|
||
int livingParty = 0;
|
||
foreach (var (pHealth, pRegion) in SystemAPI.Query<RefRO<Health>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||
if (pHealth.ValueRO.Current > 0f && pRegion.ValueRO.Region == RegionId.Expedition) livingParty++;
|
||
float partyScale = 1f + Tuning.BossHealthPerExtraPlayer * math.max(0, livingParty - 1);
|
||
var hp = SystemAPI.GetComponent<Health>(prefab);
|
||
hp.Current *= Tuning.BossHealthMultiplier * partyScale;
|
||
hp.Max *= Tuning.BossHealthMultiplier * partyScale;
|
||
ecb.SetComponent(enemy, hp);
|
||
}
|
||
if (SystemAPI.HasComponent<HitRadius>(prefab))
|
||
{
|
||
var hr = SystemAPI.GetComponent<HitRadius>(prefab);
|
||
hr.Value *= Tuning.BossScaleMultiplier;
|
||
ecb.SetComponent(enemy, hr);
|
||
}
|
||
if (SystemAPI.HasComponent<EnemyStats>(prefab))
|
||
{
|
||
var es = SystemAPI.GetComponent<EnemyStats>(prefab);
|
||
es.AttackRange *= Tuning.BossScaleMultiplier;
|
||
ecb.SetComponent(enemy, es);
|
||
}
|
||
ecb.AddComponent(enemy, new BossState { Phase = 1 });
|
||
}
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
|
||
SystemAPI.SetComponent(directorEntity, zs);
|
||
}
|
||
}
|
||
}
|