Files
Project-M/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs
T
kronic 3995af736c Boss becomes a real fight + readable-but-fair enemy threat
BossState (server-only) + BossAISystem: sole mover, seek -> telegraphed
radial slam (AttackWindup) -> phase-2 speed + swarmer summon. Excluded from
the EnemyAISystem Charger pass; knockback-immune at all 3 stamp sites;
arena-anchored spawn; scaled Health/HitRadius/AttackRange. Grunt windup now
commits in its last ~30% (whiffs out-of-range); Charger stagger roots; grunt
speed 4.2->5.2; swarmer telegraph synced. SweptMove + zone-enemy spawn-stamp
extracted to shared helpers. Zero new [GhostField].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:21:18 -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>());
state.RequireForUpdate(m_Bosses);
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;
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>())
{
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();
}
}
}