Files
Project-M/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs
T
kronic 3836e9c842 Phase 1 B3: enemies die with a corpse window instead of popping out of existence
Server: HealthApplyDamageSystem marks EnemyTag Dying{UntilTick} (TickUtil.
NonZero, ~54 ticks) on the lethal 0-crossing instead of instant destroy,
zeroes every live cue (replicated AttackWindup, LungeState + IsLunging bit,
KnockbackState), destroys on expiry; plain-world tests keep instant destroy
(no NetworkTime) so the suite's assertions stay meaningful.

Every consumer now ignores corpses (all confirmed entity-count/unfiltered by
the design review): EnemyAISystem all 4 passes, BossAISystem brain + summon
cap, RoomEnemyDirector room-clear + MaxAlive fit, WaveSystem cap + cleared,
CyclePhaseSystem breach wipe + DefendCleared, ThreatDirector timeout cull,
TurretFire targeting, CoreDamage drain, ProjectileDamage snapshot (corpses
are not shields), AbilityFire auto-aim candidates, debug cull + telemetry.
Wipe passes skip Dying to avoid cross-ECB double-destroys.

Client: EnemyAnimationDriveSystem finally drives the controller's IsDead
param (Health.Current<=0 is the replicated death read; corpse locomotion
zeroed); CombatFeedbackSystem moves the kill CRUNCH to the 0-crossing (the
prune-edge timing would land it ~1 s late) - the prune keeps a small
dissolve puff for corpses and the legacy full read for alive-vanish culls.

456/456 EditMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:34:12 -07:00

199 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Physics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of <c>.WithAll&lt;EnemyTag, BossState&gt;()</c>
/// (EnemyAISystem's Charger MOVE pass excludes it via <c>.WithNone&lt;BossState&gt;()</c>, so exactly one system
/// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain
/// <see cref="SimulationSystemGroup"/> <c>[UpdateAfter(EnemyAISystem)]</c> (a linear chain, no sort cycle), once per
/// tick (interpolated ghost, no rollback → no Simulate filter, no IsFirstTimeFullyPredictingTick).
///
/// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial
/// SLAM — the client danger cue rides the replicated <see cref="AttackWindup"/> [GhostField] (CombatFeedbackSystem
/// draws a boss-scale ring). At/below <see cref="Tuning.BossPhase2HealthFraction"/> HP it enters phase two: faster,
/// slams more often, and periodically summons swarmer adds. The boss does NOT lunge (it keeps its baked LungeState
/// idle, so EnemyAISystem's IsLunging derive sees UntilTick==0 → bit off, harmless). Knockback-immune (the stamp
/// sites skip BossState; this system also clears any residual so nothing else can shove it). Summoned adds go
/// through <see cref="ZoneEnemySpawnUtil"/> so they carry the SAME ZoneEnemyTag/RoomTag/RegionTag stack the
/// room-clear gate + teardown depend on (dropping one would leak adds or clear the room early). All ticks route
/// through <c>TickUtil.NonZero</c> and compare with <see cref="NetworkTick"/> only (never raw uint).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyAISystem))]
public partial struct BossAISystem : ISystem
{
EntityQuery m_Bosses;
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>(), ComponentType.Exclude<Dying>());
state.RequireForUpdate(m_Bosses);
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // summon cap counts LIVING only (B3)
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
float dt = SystemAPI.Time.DeltaTime;
// Living EXPEDITION players — the boss's only valid targets. Snapshot once (stable query order).
var playerEntities = new NativeList<Entity>(Allocator.Temp);
var playerPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, region, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlayerTag>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition)
continue;
playerEntities.Add(entity);
playerPositions.Add(xform.ValueRO.Position);
}
// Collide-and-slide setup (mirrors EnemyAISystem).
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
uint sweepMask = envMask | worldCol.StructureMask;
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
bool sweep = havePhysics && sweepMask != 0u;
const float SweepRadius = 0.8f; // the boss is a big body
int liveZone = m_ZoneEnemies.CalculateEntityCount();
// Summon resources (phase two): the swarmer prefab + baked transform + the current room byte.
bool haveDirector = SystemAPI.TryGetSingletonEntity<ZoneEnemyDirector>(out var directorEntity);
Entity swarmerPrefab = Entity.Null;
LocalTransform swarmerBaked = default;
if (haveDirector)
{
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length > ZoneEnemyMath.KindSwarmer)
{
swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab;
if (swarmerPrefab != Entity.Null)
swarmerBaked = state.EntityManager.GetComponentData<LocalTransform>(swarmerPrefab);
}
}
byte roomByte = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo) ? (byte)(runInfo.CurrentRoom & 0xFF) : (byte)0;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, health, boss, windup, knockback) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
RefRW<AttackWindup>, RefRW<KnockbackState>>()
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
{
float3 pos = xform.ValueRO.Position;
// Knockback-immune: never recoil (A4). Zero any residual so a competing stamp can't shove the boss.
if (knockback.ValueRO.UntilTick != 0u) knockback.ValueRW.UntilTick = 0u;
// Phase from the boss's own Current vs (server-side, real ×BossHealthMultiplier) Max.
float maxHp = math.max(1f, health.ValueRO.Max);
byte phase = health.ValueRO.Current <= maxHp * Tuning.BossPhase2HealthFraction ? (byte)2 : (byte)1;
boss.ValueRW.Phase = phase;
// Target: nearest living expedition player.
int tgt = -1; float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float d = math.distancesq(pos, playerPositions[i]);
if (d < bestSq) { bestSq = d; tgt = i; }
}
if (tgt < 0)
continue; // no valid target -> idle (InRoom-abort handles a fully-empty expedition)
float3 targetPos = playerPositions[tgt];
// Face the target (planar) at all times, incl. while telegraphing.
float3 toTarget = targetPos - pos; toTarget.y = 0f;
if (math.lengthsq(toTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
// --- SLAM in progress: root (the telegraph) until it lands, then AoE all players in the ring. ---
uint windRaw = windup.ValueRO.WindUpUntilTick;
if (windRaw != 0u)
{
var wt = new NetworkTick(windRaw);
if (!(wt.IsValid && wt.IsNewerThan(serverTick)))
{
float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius;
for (int i = 0; i < playerEntities.Length; i++)
{
if (math.distancesq(pos, playerPositions[i]) > slamSq)
continue;
ecb.AppendToBuffer(playerEntities[i], new DamageEvent
{
Amount = Tuning.BossSlamDamage,
SourceNetworkId = -1, // environment / boss, not a player
SourceTick = TickUtil.NonZero(now),
});
}
windup.ValueRW.WindUpUntilTick = 0u;
uint baseCd = Tuning.BossSlamCooldownTicks;
uint cd = phase == 2
? (uint)math.max(1f, baseCd * Tuning.BossPhase2SlamCooldownMult)
: baseCd;
boss.ValueRW.SlamReadyTick = TickUtil.NonZero(now + cd);
}
continue; // rooted while winding up (the tell); rotation already written above
}
// --- Chase (no active slam). ---
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
float stopDist = stats.ValueRO.AttackRange * 0.9f;
float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, speed, stopDist);
float3 newPos = pos + vel * dt; newPos.y = pos.y;
if (sweep) newPos = EnemyMoveUtil.SweptMove(in physics, pos, newPos, SweepRadius, envFilter);
xform.ValueRW.Position = newPos;
// Slam gate: ready + a player inside (ring + a small lead) -> commit a telegraphed slam.
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
float lead = Tuning.BossSlamRadius + 1.5f;
if (slamReady && math.distancesq(newPos, targetPos) <= lead * lead)
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossSlamWindupTicks);
// Summon (phase two only): ready + under the live cap + a swarmer prefab wired.
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
{
bool summonReady = boss.ValueRO.SummonReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SummonReadyTick).IsNewerThan(serverTick);
if (summonReady)
{
int toSpawn = math.min(Tuning.BossSummonCount, Tuning.BossSummonLiveCap - liveZone);
for (int k = 0; k < toSpawn; k++)
{
float3 spawnPos = EnemyAIMath.ClusterOffset(newPos, k, math.max(1, toSpawn), 2.5f);
spawnPos.y = newPos.y;
ZoneEnemySpawnUtil.Spawn(ecb, swarmerPrefab, in swarmerBaked, spawnPos, RegionId.Expedition, roomByte);
liveZone++;
}
boss.ValueRW.SummonReadyTick = TickUtil.NonZero(now + Tuning.BossSummonCooldownTicks);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerEntities.Dispose();
playerPositions.Dispose();
}
}
}