3995af736c
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>
199 lines
11 KiB
C#
199 lines
11 KiB
C#
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<EnemyTag, BossState>()</c>
|
||
/// (EnemyAISystem's Charger MOVE pass excludes it via <c>.WithNone<BossState>()</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();
|
||
}
|
||
}
|
||
}
|