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 { /// /// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of .WithAll<EnemyTag, BossState>() /// (EnemyAISystem's Charger MOVE pass excludes it via .WithNone<BossState>(), so exactly one system /// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain /// [UpdateAfter(EnemyAISystem)] (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 [GhostField] (CombatFeedbackSystem /// draws a boss-scale ring). At/below 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 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 TickUtil.NonZero and compare with only (never raw uint). /// [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(); m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly(), ComponentType.Exclude()); state.RequireForUpdate(m_Bosses); m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // summon cap counts LIVING only (B3) } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().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(Allocator.Temp); var playerPositions = new NativeList(Allocator.Temp); foreach (var (xform, health, region, entity) in SystemAPI.Query, RefRO, RefRO>() .WithAll().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(out var physics); uint envMask = SystemAPI.TryGetSingleton(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(out var directorEntity); Entity swarmerPrefab = Entity.Null; LocalTransform swarmerBaked = default; if (haveDirector) { var prefabs = SystemAPI.GetBuffer(directorEntity); if (prefabs.Length > ZoneEnemyMath.KindSwarmer) { swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab; if (swarmerPrefab != Entity.Null) swarmerBaked = state.EntityManager.GetComponentData(swarmerPrefab); } } byte roomByte = SystemAPI.TryGetSingleton(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, RefRO, RefRO, RefRW, RefRW, RefRW>() .WithAll().WithNone()) { 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(); } } }