Vault Re-Alignment

This commit is contained in:
2026-06-09 23:26:20 -07:00
parent a7405c3f38
commit da522efe7a
63 changed files with 119048 additions and 15 deletions
@@ -71,7 +71,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>>()
.WithAll<EnemyTag>())
.WithAll<EnemyTag>().WithNone<LungeState>())
{
float3 pos = xform.ValueRO.Position;
@@ -145,6 +145,7 @@ namespace ProjectM.Server
{
Amount = stats.ValueRO.AttackDamage,
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);
@@ -170,6 +171,138 @@ namespace ProjectM.Server
}
}
// --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge.
// Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone<LungeState>().
const float ChargerLungeSpeed = 16f; // units/s while lunging
const uint ChargerLungeDurationTicks = 18; // ~0.30 s of committed travel
const uint ChargerWindupTicks = 30; // ~0.50 s readable telegraph (>= interp delay + reaction)
const uint ChargerWhiffStaggerTicks = 36; // ~0.60 s punish window on a whiff
uint chargerWhiffsThisTick = 0;
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>>()
.WithAll<EnemyTag>())
{
float3 pos = xform.ValueRO.Position;
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
var kb = knockback.ValueRO;
if (kb.UntilTick != 0)
{
var kbTick = new NetworkTick(kb.UntilTick);
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
{
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
kpos.y = pos.y;
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0;
lunge.ValueRW.UntilTick = 0;
continue;
}
knockback.ValueRW.UntilTick = 0;
}
// Nearest living player (reuse the snapshot taken above).
int cbest = -1; float cbestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float2 dd = playerPositions[i].xz - pos.xz;
float sq = math.lengthsq(dd);
if (sq < cbestSq) { cbestSq = sq; cbest = i; }
}
float3 cTargetPos = playerPositions[cbest];
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
var lg = lunge.ValueRO;
if (lg.UntilTick != 0)
{
var lgTick = new NetworkTick(lg.UntilTick);
if (lgTick.IsValid && lgTick.IsNewerThan(serverTick))
{
float3 intended = pos + new float3(lg.Dir.x, 0f, lg.Dir.y) * (lg.Speed * dt);
intended.y = pos.y;
float3 moved = sweep ? SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
xform.ValueRW.Position = moved;
if (math.lengthsq(lg.Dir) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lg.Dir.x, 0f, lg.Dir.y), math.up());
if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange))
{
ecb.AppendToBuffer(playerEntities[cbest], new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1,
SourceTick = TickUtil.NonZero(now),
});
uint cdTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cdTicks);
lunge.ValueRW.UntilTick = 0; // landed -> end the lunge
}
else
{
float intendedDist = math.distance(pos.xz, intended.xz);
float actualDist = math.distance(pos.xz, moved.xz);
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
{
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
lunge.ValueRW.UntilTick = 0; // wall-stop whiff -> stagger (the punish window)
chargerWhiffsThisTick++;
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
}
}
continue; // committed this tick
}
// Timer elapsed without landing -> overshoot whiff -> stagger, then seek this tick.
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
lunge.ValueRW.UntilTick = 0;
chargerWhiffsThisTick++;
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
}
// 3. Seek + face (shared shape with the Grunt path).
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;
if (math.lengthsq(cToTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up());
// 4. Commit: a wind-up elapses -> LOCK the lunge direction + fire. NO cancel-on-leave-range — the
// whole point is the commit lands even if the player dodged out of range (the punishable tell).
uint cWindRaw = windup.ValueRO.WindUpUntilTick;
if (cWindRaw != 0)
{
var cWindTick = new NetworkTick(cWindRaw);
if (!(cWindTick.IsValid && cWindTick.IsNewerThan(serverTick)))
{
float3 toT = cTargetPos - pos; toT.y = 0f;
float2 ldir = math.lengthsq(toT) > 1e-6f ? math.normalize(toT.xz) : new float2(0f, 1f);
lunge.ValueRW.Dir = ldir;
lunge.ValueRW.Speed = ChargerLungeSpeed;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + ChargerLungeDurationTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
}
else
{
bool cInRange = EnemyAIMath.InAttackRange(pos, cTargetPos, stats.ValueRO.AttackRange);
if (cInRange)
{
bool cReady = cooldown.ValueRO.NextAttackTick == 0
|| !new NetworkTick(cooldown.ValueRO.NextAttackTick).IsNewerThan(serverTick);
if (cReady)
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + ChargerWindupTicks);
}
}
}
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
ecb.Playback(state.EntityManager);
ecb.Dispose();
@@ -25,6 +25,12 @@ namespace ProjectM.Server
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(ProjectileDamageSystem))]
// Pin the drain AFTER DashSystem: a same-tick player-sourced projectile (ProjectileDamageSystem stamps
// SourceTick = now and this system drains the SAME tick) must see a dash window STARTED this tick —
// without the edge the negation at src == StartTick is an unconstrained sorter tiebreak. The Dash chain
// (StatRecompute→PlayerControl→Dash) and the projectile chain (PlayerAim→AbilityFire→ProjectileMove→
// ProjectileDamage→here) are otherwise disjoint, so this edge cannot form a cycle (Play-validated).
[UpdateAfter(typeof(DashSystem))]
[BurstCompile]
public partial struct HealthApplyDamageSystem : ISystem
{
@@ -33,6 +39,8 @@ namespace ProjectM.Server
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var netTime);
uint negatedThisTick = 0;
uint punishesThisTick = 0;
foreach (var (health, dmg, entity) in
SystemAPI.Query<RefRW<Health>, DynamicBuffer<DamageEvent>>()
@@ -63,10 +71,55 @@ namespace ProjectM.Server
}
}
bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<DashState>(entity);
DashState ds = hasDash ? SystemAPI.GetComponent<DashState>(entity) : default;
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
uint negatedForThisEntity = 0u;
float total = 0f;
for (int i = 0; i < dmg.Length; i++)
{
uint src = dmg[i].SourceTick;
if (hasDash && src != 0u && ds.IFrameUntilTick != 0u)
{
var srcTick = new NetworkTick(src);
var startTick = new NetworkTick(ds.StartTick);
var untilTick = new NetworkTick(ds.IFrameUntilTick);
// Dash i-frames cover the HALF-OPEN window [StartTick, IFrameUntilTick): negate iff src >= start AND src < until.
bool atOrAfterStart = srcTick.IsValid && startTick.IsValid && !startTick.IsNewerThan(srcTick);
bool beforeUntil = untilTick.IsValid && untilTick.IsNewerThan(srcTick);
if (atOrAfterStart && beforeUntil)
{
negatedThisTick++;
negatedForThisEntity++;
continue; // dash i-frame negates this hit (per-element, not a whole-buffer clear)
}
}
total += dmg[i].Amount;
// MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's
// whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
if (isCharger && dmg[i].SourceNetworkId >= 0)
{
var lunge = SystemAPI.GetComponent<LungeState>(entity);
if (lunge.StaggerUntilTick != 0u)
{
var stag = new NetworkTick(lunge.StaggerUntilTick);
if (stag.IsValid && stag.IsNewerThan(netTime.ServerTick))
{
punishesThisTick++;
lunge.StaggerUntilTick = 0u;
SystemAPI.SetComponent(entity, lunge);
}
}
}
}
dmg.Clear();
if (negatedForThisEntity != 0u)
{
ds.NegatedCount += negatedForThisEntity; // server-side spam signal; DashSystem reads it at window-close
SystemAPI.SetComponent(entity, ds);
}
float newHp = health.ValueRO.Current - total;
@@ -83,6 +136,12 @@ namespace ProjectM.Server
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity)))
ecb.DestroyEntity(entity);
}
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
{
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
telem.ValueRW.DashIFrameNegatedHits += negatedThisTick;
telem.ValueRW.ChargerWhiffPunishesLanded += punishesThisTick;
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
@@ -130,6 +130,7 @@ namespace ProjectM.Server
{
Amount = proj.ValueRO.Damage,
SourceNetworkId = projOwnerId,
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
});
var hitTarget = targetEntities[bestIdx];
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))