This commit is contained in:
2026-07-07 20:51:18 -07:00
parent d2203c8aa1
commit cc2e7ad95e
20 changed files with 794 additions and 35 deletions
@@ -105,6 +105,17 @@ namespace ProjectM.Server
bool sweep = havePhysics && sweepMask != 0u;
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
// Anti-stuck (1/2): DEPENETRATE every living enemy out of any env/cover it overlaps BEFORE seeking, so a
// Husk spawned inside or shoved into a cover rock isn't frozen by a zero-fraction sweep (the sweep cannot
// move a mover that STARTS already penetrating). One point-distance query per living enemy (<=~15/room).
// Boss excluded (BossAISystem owns it at a larger radius).
if (sweep)
{
foreach (var depenXform in SystemAPI.Query<RefRW<LocalTransform>>()
.WithAll<EnemyTag>().WithNone<Dying, BossState>())
depenXform.ValueRW.Position = EnemyMoveUtil.Depenetrate(in physics, depenXform.ValueRO.Position, SweepRadius, envFilter);
}
foreach (var (xform, stats, cooldown, knockback, windup, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
@@ -548,6 +559,84 @@ namespace ProjectM.Server
sepEnt.Dispose(); sepPos.Dispose(); sepRad.Dispose(); sepMov.Dispose();
}
// Anti-stuck (2/2): the GUARANTEED backstop. An enemy that WANTS to close on its target (outside attack
// range, not knocked/lunging/staggered) but hasn't gained ground on it for StuckUnstickTicks is phase-
// nudged toward the nearest target (collide-and-slide bypassed) until it escapes -> a room can never
// soft-lock on one Husk wedged in geometry the reactive slide can't solve. Progress is measured as
// distance CLOSED toward the target (not raw displacement) so the separation jiggle can't mask a stuck
// Husk. Server-only; EnemyNavState is not replicated. Spitters hold at range by design (excluded); the
// boss is driven by BossAISystem (excluded).
{
const float StuckMinProgressPerTick = 0.02f;
const uint StuckUnstickTicks = 90u; // ~1.5s of no ground gained before the nudge triggers
const uint NudgeBurstTicks = 45u; // ~0.75s of phasing toward the target per trigger
const float UnstickNudgeSpeed = 4.5f; // units/s while phase-nudging out of geometry
float nudgeStep = UnstickNudgeSpeed * dt;
foreach (var (nxform, nstats, nav, nregion, nent) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyNavState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>().WithEntityAccess())
{
float3 npos = nxform.ValueRO.Position;
byte nRegion = nregion.ValueRO.Region;
bool committed = false;
if (SystemAPI.HasComponent<KnockbackState>(nent))
{
var k = SystemAPI.GetComponent<KnockbackState>(nent);
committed |= k.UntilTick != 0 && new NetworkTick(k.UntilTick).IsNewerThan(serverTick);
}
if (SystemAPI.HasComponent<LungeState>(nent))
{
var l = SystemAPI.GetComponent<LungeState>(nent);
committed |= (l.UntilTick != 0 && new NetworkTick(l.UntilTick).IsNewerThan(serverTick))
|| (l.StaggerUntilTick != 0 && new NetworkTick(l.StaggerUntilTick).IsNewerThan(serverTick));
}
EnemyAIMath.PickWeightedNearest(npos, playerPositions, playerRegions, structurePositions, structureRegions, nRegion, structAggro, out bool nIsStruct, out int nIdx);
bool nCoreAlive = coreAlive && nRegion == RegionId.Base;
bool hasTarget = nIdx >= 0 || nCoreAlive;
float3 nTarget = nIdx < 0 ? corePos : (nIsStruct ? structurePositions[nIdx] : playerPositions[nIdx]);
bool wantsToClose = hasTarget && !committed
&& math.distance(npos.xz, nTarget.xz) > nstats.ValueRO.AttackRange * 1.15f;
bool nudging = nav.ValueRO.NudgeUntilTick != 0 && new NetworkTick(nav.ValueRO.NudgeUntilTick).IsNewerThan(serverTick);
if (nudging)
{
if (wantsToClose)
{
float3 toT = nTarget - npos; toT.y = 0f;
float l2 = math.lengthsq(toT);
if (l2 > 1e-6f)
{
float3 step = math.normalize(toT) * math.min(nudgeStep, math.sqrt(l2));
float3 np = npos + step; np.y = npos.y;
nxform.ValueRW.Position = np;
nav.ValueRW.LastPos = np;
continue;
}
}
nav.ValueRW.NudgeUntilTick = 0; // reached range / lost target -> stop nudging
}
if (!wantsToClose)
{
nav.ValueRW.StuckTicks = 0u;
nav.ValueRW.LastPos = npos;
continue;
}
float progressToward = math.distance(nav.ValueRO.LastPos.xz, nTarget.xz) - math.distance(npos.xz, nTarget.xz);
uint st = progressToward < StuckMinProgressPerTick ? nav.ValueRO.StuckTicks + 1u : 0u;
nav.ValueRW.LastPos = npos;
if (st >= StuckUnstickTicks)
{
nav.ValueRW.NudgeUntilTick = TickUtil.NonZero(now + NudgeBurstTicks);
st = 0u;
}
nav.ValueRW.StuckTicks = st;
}
}
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
@@ -13,8 +13,10 @@ namespace ProjectM.Server
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>
/// against the static environment (boundary ring + landmarks + cover rocks + player-built walls) and stop at /
/// glance along wall hits. Runs up to TWO slide iterations so a concave pocket (a cover rock meeting the
/// boundary rim) glances along BOTH surfaces instead of dead-stopping at the first (a single iteration left
/// enemies parked in pockets). 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;
@@ -22,27 +24,62 @@ namespace ProjectM.Server
float dist = math.length(delta);
if (dist < 1e-5f)
return to;
float3 dir = delta / dist;
const float skin = 0.05f;
var cw = physics.CollisionWorld;
float3 dir = delta / dist;
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;
float3 pos = from + dir * allowed;
pos.y = from.y;
float3 remaining = to - pos;
// 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;
for (int i = 0; i < 2; i++)
{
float3 slide = EnemyAIMath.SlideVelocity(remaining, hit.SurfaceNormal);
float slideDist = math.length(slide);
if (slideDist < 1e-5f)
break;
float3 sdir = slide / slideDist;
if (cw.SphereCast(pos, radius, sdir, slideDist, out var hit2, filter))
{
float sAllowed = math.max(0f, hit2.Fraction * slideDist - skin);
pos += sdir * sAllowed;
remaining = slide - sdir * sAllowed; // carry the still-unused motion into the next glance
hit = hit2;
}
else
{
pos += slide;
break;
}
}
pos.y = from.y;
return pos;
}
/// <summary>Push a sphere at <paramref name="pos"/> out of any static env/structure it overlaps, along the
/// nearest surface normal (flattened to XZ; a near-vertical floor/ceiling normal is ignored so movers aren't
/// shoved off their plane). Fixes the "spawned or shoved INTO a rock -> SphereCast fraction ~0 -> frozen
/// forever" class: the collide-and-slide sweep can never move a mover that STARTS already penetrating, so we
/// un-embed it first. The correction is capped at one radius/tick so it eases out over a couple of ticks
/// rather than teleporting. Stateless; called once per enemy per tick before seeking.</summary>
public static float3 Depenetrate(in PhysicsWorldSingleton physics, float3 pos, float radius, CollisionFilter filter)
{
var cw = physics.CollisionWorld;
var input = new PointDistanceInput { Position = pos, MaxDistance = radius + 0.5f, Filter = filter };
if (!cw.CalculateDistance(input, out var hit) || hit.Distance >= radius)
return pos;
float3 n = hit.SurfaceNormal;
n.y = 0f;
float len = math.length(n);
if (len < 1e-5f)
return pos; // floor/ceiling normal -> don't shove the mover off its movement plane
float push = math.min(radius - hit.Distance, radius); // cap so a deep embed eases out over ticks, no pop
float3 outp = pos + (n / len) * push;
outp.y = pos.y;
return outp;
}
}
}