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>
This commit is contained in:
2026-07-05 20:21:18 -07:00
parent c6b7890212
commit 3995af736c
16 changed files with 456 additions and 70 deletions
+1 -1
View File
@@ -927,7 +927,7 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 30 MaxHealth: 30
HitRadius: 0.7 HitRadius: 0.7
MoveSpeed: 4.2 MoveSpeed: 5.2
AttackRange: 1.6 AttackRange: 1.6
AttackDamage: 5 AttackDamage: 5
AttackCooldownTicks: 48 AttackCooldownTicks: 48
@@ -67,7 +67,7 @@ namespace ProjectM.Authoring
Debug.LogError($"Enemy '{authoring.name}' has BOTH ChargerAuthoring and SpitterAuthoring; it would match no AI pass and never move. Remove one.", authoring); Debug.LogError($"Enemy '{authoring.name}' has BOTH ChargerAuthoring and SpitterAuthoring; it would match no AI pass and never move. Remove one.", authoring);
if (GetComponent<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; } if (GetComponent<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; }
else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); } else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); }
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; } else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = (byte)Tuning.AttackWindupTicks; /* B4: match the server windup (grunt-path swarmers use GruntWindupTicks); baked 6 was a snap-ramp lie */ }
AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind }); AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind });
} }
} }
@@ -0,0 +1,198 @@
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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 105d73021b780c449a16ea72bcc29b69
@@ -165,25 +165,32 @@ namespace ProjectM.Server
if (windRaw != 0) if (windRaw != 0)
{ {
if (!inRange) // B1 commitment: once inside the final ~30% of the wind-up the strike is COMMITTED — leaving
// range no longer cancels it (a last-instant step-back won't save you; dash i-frames or an early
// exit will). A committed swing the player dodged out of WHIFFS at elapse (still burns cooldown)
// -> the punish window, mirroring the Charger. Grunts (speed < player) can now actually land on a
// lingering player instead of being trivially kited (readable-but-fair).
var windTick = new NetworkTick(windRaw);
bool elapsed = !(windTick.IsValid && windTick.IsNewerThan(serverTick));
int remain = windTick.IsValid ? windTick.TicksSince(serverTick) : 0;
uint gTotal = (uint)math.max(1f, tune.GruntWindupTicks);
bool committed = remain > 0 && remain <= (int)(gTotal * 0.3f);
if (!elapsed)
{ {
windup.ValueRW.WindUpUntilTick = 0; // target left range -> cancel the wind-up if (!inRange && !committed)
windup.ValueRW.WindUpUntilTick = 0; // left range early -> cancel the wind-up
} }
else else
{ {
var windTick = new NetworkTick(windRaw); if (inRange && targetEntity != Entity.Null) ecb.AppendToBuffer(targetEntity, new DamageEvent
if (!(windTick.IsValid && windTick.IsNewerThan(serverTick)))
{ {
if (targetEntity != Entity.Null) ecb.AppendToBuffer(targetEntity, new DamageEvent Amount = stats.ValueRO.AttackDamage,
{ SourceNetworkId = -1, // environment / Husk, not a player
Amount = stats.ValueRO.AttackDamage, SourceTick = TickUtil.NonZero(now),
SourceNetworkId = -1, // environment / Husk, not a player });
SourceTick = TickUtil.NonZero(now), uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
}); cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks); windup.ValueRW.WindUpUntilTick = 0;
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
} }
} }
else if (inRange) else if (inRange)
@@ -216,7 +223,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>, SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>() RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<SpitterState>()) .WithAll<EnemyTag>().WithNone<SpitterState, BossState>())
{ {
float3 pos = xform.ValueRO.Position; float3 pos = xform.ValueRO.Position;
byte cHuskRegion = region.ValueRO.Region; byte cHuskRegion = region.ValueRO.Region;
@@ -297,12 +304,18 @@ namespace ProjectM.Server
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
} }
// 3. Seek + face (shared shape with the Grunt path). // 3. Seek + face (shared shape with the Grunt path). B3: a whiffed Charger is ROOTED during its
float cStop = stats.ValueRO.AttackRange * 0.9f; // stagger punish window so the advertised punish reads (the player sees it stop). Facing still tracks.
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop); bool cStaggered = lunge.ValueRO.StaggerUntilTick != 0u
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y; && new NetworkTick(lunge.ValueRO.StaggerUntilTick).IsNewerThan(serverTick);
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter); if (!cStaggered)
xform.ValueRW.Position = cNewPos; {
float cStop = stats.ValueRO.AttackRange * 0.9f;
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop);
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y;
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = cNewPos;
}
float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f; float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f;
if (math.lengthsq(cToTarget) > 1e-6f) if (math.lengthsq(cToTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up()); xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up());
@@ -472,37 +485,10 @@ namespace ProjectM.Server
structureRegions.Dispose(); structureRegions.Dispose();
} }
// Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against // Swept collide-and-slide for server-authoritative enemy movement — delegates to the shared
// the static environment (boundary ring + landmarks) and stop at / glance along the first wall hit. Closest- // EnemyMoveUtil.SweptMove (extracted so BossAISystem reuses ONE collide-and-slide impl; a fix reaches both).
// hit SphereCast is non-generic -> Burst-safe (CLAUDE.md generic-collector hazard avoided). Y is held flat. // Kept as a private wrapper so this file's many call sites read unchanged.
static float3 SweptMove(in PhysicsWorldSingleton physics, float3 from, float3 to, float radius, CollisionFilter filter) static float3 SweptMove(in PhysicsWorldSingleton physics, float3 from, float3 to, float radius, CollisionFilter filter)
{ => EnemyMoveUtil.SweptMove(in physics, from, to, radius, filter);
float3 delta = to - from;
delta.y = 0f;
float dist = math.length(delta);
if (dist < 1e-5f)
return to;
float3 dir = delta / dist;
const float skin = 0.05f;
var cw = physics.CollisionWorld;
if (!cw.SphereCast(from, radius, dir, dist, out var hit, filter))
return to;
float allowed = math.max(0f, hit.Fraction * dist - skin);
float3 stop = from + dir * allowed;
stop.y = from.y;
// Slide the unused motion along the wall, then sweep the slide so we don't tunnel a second wall.
float3 slide = EnemyAIMath.SlideVelocity(to - stop, hit.SurfaceNormal);
float slideDist = math.length(slide);
if (slideDist < 1e-5f)
return stop;
float3 sdir = slide / slideDist;
float3 result = cw.SphereCast(stop, radius, sdir, slideDist, out var hit2, filter)
? stop + sdir * math.max(0f, hit2.Fraction * slideDist - skin)
: stop + slide;
result.y = from.y;
return result;
}
} }
} }
@@ -0,0 +1,48 @@
using ProjectM.Simulation;
using Unity.Mathematics;
using Unity.Physics;
namespace ProjectM.Server
{
/// <summary>
/// Shared server-side collide-and-slide mover for OWNERLESS enemy ghosts (extracted from EnemyAISystem so the
/// boss brain reuses ONE copy — a fix to the tunnelling sweep or wall-glance reaches every enemy at once).
/// Both EnemyAISystem's three passes and BossAISystem call <see cref="SweptMove"/>. Pure given the CollisionWorld;
/// Burst-safe (non-generic closest-hit SphereCast, per the CLAUDE.md generic-collector hazard).
/// </summary>
public static class EnemyMoveUtil
{
/// <summary>Collide-and-slide sphere-cast for server-authoritative enemy movement: sweep the intended step
/// against the static environment (boundary ring + landmarks + player-built walls) and stop at / glance along
/// the first wall hit. Y is held flat (top-down movement plane).</summary>
public static float3 SweptMove(in PhysicsWorldSingleton physics, float3 from, float3 to, float radius, CollisionFilter filter)
{
float3 delta = to - from;
delta.y = 0f;
float dist = math.length(delta);
if (dist < 1e-5f)
return to;
float3 dir = delta / dist;
const float skin = 0.05f;
var cw = physics.CollisionWorld;
if (!cw.SphereCast(from, radius, dir, dist, out var hit, filter))
return to;
float allowed = math.max(0f, hit.Fraction * dist - skin);
float3 stop = from + dir * allowed;
stop.y = from.y;
// Slide the unused motion along the wall, then sweep the slide so we don't tunnel a second wall.
float3 slide = EnemyAIMath.SlideVelocity(to - stop, hit.SurfaceNormal);
float slideDist = math.length(slide);
if (slideDist < 1e-5f)
return stop;
float3 sdir = slide / slideDist;
float3 result = cw.SphereCast(stop, radius, sdir, slideDist, out var hit2, filter)
? stop + sdir * math.max(0f, hit2.Fraction * slideDist - skin)
: stop + slide;
result.y = from.y;
return result;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5db3fdcf478dbe74a9912884c1320f16
@@ -43,6 +43,11 @@ namespace ProjectM.Server
/// <summary>RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).</summary> /// <summary>RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).</summary>
ComponentLookup<KnockbackState> m_KnockbackLookup; ComponentLookup<KnockbackState> m_KnockbackLookup;
/// <summary>Read-only lookup so a BOSS (BossState) is skipped by the knockback stamp — the boss is
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
ComponentLookup<BossState> m_BossLookup;
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary> /// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
const float k_ProjectileRadius = 0.2f; const float k_ProjectileRadius = 0.2f;
@@ -51,6 +56,8 @@ namespace ProjectM.Server
{ {
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true); m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false); m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely. // No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
state.RequireForUpdate<Projectile>(); state.RequireForUpdate<Projectile>();
@@ -61,6 +68,8 @@ namespace ProjectM.Server
{ {
m_GhostOwnerLookup.Update(ref state); m_GhostOwnerLookup.Update(ref state);
m_KnockbackLookup.Update(ref state); m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt); bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
float dt = SystemAPI.Time.DeltaTime; float dt = SystemAPI.Time.DeltaTime;
@@ -133,7 +142,7 @@ namespace ProjectM.Server
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u, SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
}); });
var hitTarget = targetEntities[bestIdx]; var hitTarget = targetEntities[bestIdx];
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget)) if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
{ {
m_KnockbackLookup[hitTarget] = new KnockbackState m_KnockbackLookup[hitTarget] = new KnockbackState
{ {
@@ -145,7 +145,7 @@ namespace ProjectM.Server
baseCenter = BaseGridMath.PlotCenter(anchor); baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
float3 center = bossRoom float3 center = bossRoom
? origin // the boss anchors the room center ? origin + new float3(0f, 0f, 12f) // the boss anchors the room center
: EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius); : EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y; center.y = origin.y;
@@ -160,20 +160,35 @@ namespace ProjectM.Server
float3 pos = packSize > 1 float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center; ? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y; pos.y = origin.y;
var enemy = ecb.Instantiate(prefab); var enemy = ZoneEnemySpawnUtil.Spawn(ecb, prefab, in baked, pos, RegionId.Expedition, room);
var xform = baked.WithPosition(pos); // preserve the baked [GhostField] Scale
if (bossRoom) if (bossRoom)
xform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, xform);
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.AddComponent(enemy, new RoomTag { Room = room });
if (bossRoom && SystemAPI.HasComponent<Health>(prefab))
{ {
var hp = SystemAPI.GetComponent<Health>(prefab); // Boss = a scaled Charger given a real kit by BossAISystem. Scale the visual AND the
hp.Current *= Tuning.BossHealthMultiplier; // hitbox/reach (so hits register on the big model + its reach matches), multiply Health,
hp.Max *= Tuning.BossHealthMultiplier; // and tag BossState (server-only discriminator) so BossAISystem alone drives it.
ecb.SetComponent(enemy, hp); var bxform = baked.WithPosition(pos);
bxform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, bxform);
if (SystemAPI.HasComponent<Health>(prefab))
{
var hp = SystemAPI.GetComponent<Health>(prefab);
hp.Current *= Tuning.BossHealthMultiplier;
hp.Max *= Tuning.BossHealthMultiplier;
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.Playback(state.EntityManager);
@@ -0,0 +1,29 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// The ONE per-enemy spawn-stamp for expedition ROOM enemies, shared by RoomEnemyDirectorSystem (wave drip) and
/// BossAISystem (phase-two summon). Keeps the teardown/relevancy/clear-count contract in a single place so a boss
/// add can never silently drop a tag: <see cref="ZoneEnemyTag"/> (counted by the room-clear gate),
/// <see cref="RoomTag"/> (the RoomTeardown filter — Room byte MUST equal the current room), and
/// <see cref="RegionTag"/>{Expedition} (relevancy — an untagged add would leak to base players, a Base-tagged one
/// would hide from the expedition party). Scale preserved via <c>baked.WithPosition</c> (never FromPosition, which
/// resets the [GhostField] Scale). Returns the spawned entity so a caller can layer extra (e.g. boss HP/scale).
/// </summary>
public static class ZoneEnemySpawnUtil
{
public static Entity Spawn(EntityCommandBuffer ecb, Entity prefab, in LocalTransform baked, float3 pos, byte region, byte room)
{
var enemy = ecb.Instantiate(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos)); // preserve the baked [GhostField] Scale
ecb.AddComponent(enemy, new RegionTag { Region = region });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.AddComponent(enemy, new RoomTag { Room = room });
return enemy;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 73476d3f27c37514f8f681b480091a21
@@ -37,11 +37,17 @@ namespace ProjectM.Simulation
[BurstCompile] [BurstCompile]
public partial struct AbilityFireSystem : ISystem public partial struct AbilityFireSystem : ISystem
{ {
// C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
state.RequireForUpdate<AbilityDatabase>(); state.RequireForUpdate<AbilityDatabase>();
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
} }
[BurstCompile] [BurstCompile]
@@ -63,6 +69,8 @@ namespace ProjectM.Simulation
var abilityDb = SystemAPI.GetSingleton<AbilityDatabase>(); var abilityDb = SystemAPI.GetSingleton<AbilityDatabase>();
bool isServer = state.WorldUnmanaged.IsServer(); bool isServer = state.WorldUnmanaged.IsServer();
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
// Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad // Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad
// auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave. // auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave.
@@ -132,6 +140,19 @@ namespace ProjectM.Simulation
SourceNetworkId = owner.ValueRO.NetworkId, SourceNetworkId = owner.ValueRO.NetworkId,
SourceTick = cStamp, SourceTick = cStamp,
}); });
// C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee
// (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4).
if (m_KnockbackLookup.HasComponent(coneTargets[ci]) && !m_BossLookup.HasComponent(coneTargets[ci]))
{
float3 kd3 = coneTargetPos[ci] - xform.ValueRO.Position;
float2 kdir = math.lengthsq(kd3.xz) > 1e-6f ? math.normalize(kd3.xz) : cFace;
m_KnockbackLookup[coneTargets[ci]] = new KnockbackState
{
Dir = kdir,
Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
};
}
} }
} }
uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks); uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
@@ -0,0 +1,31 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// SERVER-ONLY working state for the expedition BOSS (a scaled Charger that <see cref="Server"/>'s
/// RoomEnemyDirectorSystem tags at spawn). NOT replicated and NOT baked — added at runtime via ECB on the boss
/// entity, so it needs no ghost-hash change (a runtime-added replicated component would not replicate anyway;
/// this one is deliberately server-only, like <see cref="LungeState"/>/<see cref="KnockbackState"/>).
/// <para>
/// Component PRESENCE is the boss discriminator: BossAISystem is the SOLE mover/attacker of
/// <c>.WithAll&lt;EnemyTag, BossState&gt;()</c>, and EnemyAISystem's Charger MOVE pass excludes it via
/// <c>.WithNone&lt;BossState&gt;()</c> so exactly one system writes the boss's Position/AttackWindup. The boss does
/// NOT use LungeState (its signature move is a telegraphed radial SLAM, not a lunge) — so EnemyAISystem's
/// IsLunging derive visits it but sees <c>LungeState.UntilTick==0</c> and derives the bit off (harmless single
/// writer). <see cref="Phase"/> is a byte (never a C# enum on a Bursted path — the cross-assembly-enum ICE rule).
/// All tick fields route through <c>TickUtil.NonZero</c> and compare with <see cref="Unity.NetCode.NetworkTick"/>.
/// </para>
/// </summary>
public struct BossState : IComponentData
{
/// <summary>1 = phase one (heavy Charger + slam), 2 = phase two (&lt;50% HP: faster + summons adds). Byte, not enum.</summary>
public byte Phase;
/// <summary>Earliest raw tick the boss may begin its next radial SLAM wind-up (NonZero; 0 = ready).</summary>
public uint SlamReadyTick;
/// <summary>Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready).</summary>
public uint SummonReadyTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fdf576e1f07162e43bae89c4ccc06dec
@@ -49,6 +49,8 @@ namespace ProjectM.Simulation
public partial struct MeleeComboSystem : ISystem public partial struct MeleeComboSystem : ISystem
{ {
ComponentLookup<KnockbackState> m_KnockbackLookup; ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup; // A4: the boss is knockback-immune (no melee stunlock out of its slams)
ComponentLookup<RegionTag> m_RegionLookup; ComponentLookup<RegionTag> m_RegionLookup;
BufferLookup<InventorySlot> m_InvLookup; BufferLookup<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup; BufferLookup<StatModifier> m_StatModLookup;
@@ -57,6 +59,8 @@ namespace ProjectM.Simulation
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false); m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true); m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false); m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true); m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
@@ -224,6 +228,8 @@ namespace ProjectM.Simulation
m_KnockbackLookup.Update(ref state); m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int s = 0; s < cleaves.Length; s++) for (int s = 0; s < cleaves.Length; s++)
{ {
@@ -239,7 +245,7 @@ namespace ProjectM.Simulation
SourceNetworkId = c.OwnerId, SourceNetworkId = c.OwnerId,
SourceTick = c.Stamp, SourceTick = c.Stamp,
}); });
if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target)) if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target) && !m_BossLookup.HasComponent(target))
{ {
float3 d3 = enemyPositions[i] - c.From; float3 d3 = enemyPositions[i] - c.From;
float2 kdir = new float2(d3.x, d3.z); float2 kdir = new float2(d3.x, d3.z);
+36 -1
View File
@@ -72,7 +72,7 @@ namespace ProjectM.Simulation
/// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps /// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps
/// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any /// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any
/// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary> /// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary>
public const int StartingOre = 50; public const int StartingOre = 90;
// ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ---- // ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ----
@@ -95,6 +95,41 @@ namespace ProjectM.Simulation
/// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence.</summary> /// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence.</summary>
public const uint RoomEntryGraceTicks = 100; public const uint RoomEntryGraceTicks = 100;
// ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ----
/// <summary>Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit
/// unless dashing (i-frames). Sized well above melee reach so the tell reads as a boss-scale ring.</summary>
public const float BossSlamRadius = 5.5f;
/// <summary>Radial SLAM damage. Heavy but survivable given generous i-frames + respawn (readable-but-fair).</summary>
public const float BossSlamDamage = 34f;
/// <summary>SLAM telegraph lead (~0.75 s @60): a fair dodge window under interp lag before the ring lands.</summary>
public const uint BossSlamWindupTicks = 45;
/// <summary>Ticks between SLAMs in phase one (~4 s). Phase two multiplies this by BossPhase2SlamCooldownMult.</summary>
public const uint BossSlamCooldownTicks = 240;
/// <summary>Health fraction at/below which the boss enters phase two (faster + summons adds).</summary>
public const float BossPhase2HealthFraction = 0.5f;
/// <summary>Phase-two move-speed multiplier over the boss's base EnemyStats.MoveSpeed (a visible gear shift).</summary>
public const float BossPhase2SpeedMult = 1.45f;
/// <summary>Phase-two SLAM cooldown multiplier (&lt;1 = slams more often when enraged).</summary>
public const float BossPhase2SlamCooldownMult = 0.6f;
/// <summary>Swarmers summoned per phase-two summon (spawned around the boss, tagged as room zone enemies).</summary>
public const int BossSummonCount = 3;
/// <summary>Ticks between phase-two summons (~7 s).</summary>
public const uint BossSummonCooldownTicks = 420;
/// <summary>Cap on live zone enemies the boss will summon toward — it holds fire above this so a boss room
/// never floods past readable (also bounded by the director's MaxAlive count).</summary>
public const int BossSummonLiveCap = 8;
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary> /// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>