Vault Re-Alignment
This commit is contained in:
@@ -96,6 +96,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
Amount = turret.ValueRO.Damage,
|
||||
SourceNetworkId = -1,
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
uint cd = (uint)math.max(1, turret.ValueRO.CooldownTicks);
|
||||
ps.ValueRW.NextTick = TickUtil.NonZero(now + cd);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#if UNITY_EDITOR
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-0 — EDITOR-ONLY server telemetry sampler/sender. Ensures the <see cref="DevTelemetry"/> singleton,
|
||||
/// samples live-enemy-count + the server tick each tick, and every <see cref="ReportPeriodTicks"/> ships a
|
||||
/// <see cref="DebugTelemetryReport"/> snapshot to every connection (so the dev overlay shows live fun-gate
|
||||
/// counters over a real connection too). Combat systems increment the real counters at the stamp sites (MC-1+).
|
||||
/// Plain server <see cref="SimulationSystemGroup"/> (NOT the predicted loop); non-Burst (managed-simple,
|
||||
/// editor-only). Stripped from builds; the wire TYPE <see cref="DebugTelemetryReport"/> is unconditional.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct DevTelemetrySystem : ISystem
|
||||
{
|
||||
const uint ReportPeriodTicks = 15;
|
||||
EntityQuery m_Husks;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
if (state.GetEntityQuery(ComponentType.ReadWrite<DevTelemetry>()).IsEmpty)
|
||||
state.EntityManager.CreateEntity(typeof(DevTelemetry));
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
|
||||
telem.ValueRW.LiveEnemyCount = (uint)m_Husks.CalculateEntityCount();
|
||||
telem.ValueRW.LastSampleTick = now;
|
||||
|
||||
if (now == 0 || (now % ReportPeriodTicks) != 0)
|
||||
return;
|
||||
|
||||
var t = telem.ValueRO;
|
||||
var report = new DebugTelemetryReport
|
||||
{
|
||||
DashIFrameNegatedHits = t.DashIFrameNegatedHits,
|
||||
DashesWasted = t.DashesWasted,
|
||||
ChargerWhiffWindowsOpened = t.ChargerWhiffWindowsOpened,
|
||||
ChargerWhiffPunishesLanded = t.ChargerWhiffPunishesLanded,
|
||||
LiveEnemyCount = t.LiveEnemyCount,
|
||||
LastSampleTick = t.LastSampleTick,
|
||||
};
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (netId, connEnt) in SystemAPI.Query<RefRO<NetworkId>>().WithEntityAccess())
|
||||
{
|
||||
var req = ecb.CreateEntity();
|
||||
ecb.AddComponent(req, report);
|
||||
ecb.AddComponent(req, new SendRpcCommandRequest { TargetConnection = connEnt });
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef1e1f5e7e01b77489dcb181652176a0
|
||||
Reference in New Issue
Block a user