Further Tests & Progress

This commit is contained in:
2026-06-04 11:35:57 -07:00
parent 5c11ff4fad
commit 51401d2c2b
65 changed files with 2784 additions and 45 deletions
@@ -59,14 +59,32 @@ namespace ProjectM.Server
float dt = SystemAPI.Time.DeltaTime;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
uint now = serverTick.TickIndexForValidTick;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, cooldown) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>>()
foreach (var (xform, stats, cooldown, knockback, windup) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>>()
.WithAll<EnemyTag>())
{
float3 pos = xform.ValueRO.Position;
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
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;
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0; // a recoiling Husk does not wind up
continue; // recoiling: skip seek + strike this tick
}
knockback.ValueRW.UntilTick = 0; // window elapsed
}
// Nearest living player (planar XZ).
int best = -1;
float bestSq = float.MaxValue;
@@ -96,8 +114,35 @@ namespace ProjectM.Server
if (math.lengthsq(toTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
// Strike on contact once the cooldown has elapsed.
if (EnemyAIMath.InAttackRange(pos, targetPos, stats.ValueRO.AttackRange))
// Two-phase strike with a telegraph wind-up: commit a wind-up when first in-range + cooldown-ready,
// then strike when it elapses. WindUpUntilTick is a [GhostField] so the client can cue the ~0.3s
// tell; leaving range mid-windup cancels it. Tuning.AttackWindupTicks = 0/1 -> near-instant (legacy).
bool inRange = EnemyAIMath.InAttackRange(pos, targetPos, stats.ValueRO.AttackRange);
uint windRaw = windup.ValueRO.WindUpUntilTick;
if (windRaw != 0)
{
if (!inRange)
{
windup.ValueRW.WindUpUntilTick = 0; // target left range -> cancel the wind-up
}
else
{
var windTick = new NetworkTick(windRaw);
if (!(windTick.IsValid && windTick.IsNewerThan(serverTick)))
{
ecb.AppendToBuffer(playerEntities[best], new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
}
}
else if (inRange)
{
uint nextRaw = cooldown.ValueRO.NextAttackTick;
bool ready = true;
@@ -107,16 +152,10 @@ namespace ProjectM.Server
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
ready = false;
}
if (ready)
{
ecb.AppendToBuffer(playerEntities[best], new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + cooldownTicks);
uint windupTicks = (uint)math.max(1, Tuning.AttackWindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + windupTicks);
}
}
}
@@ -40,6 +40,9 @@ namespace ProjectM.Server
/// <summary>Lookup used to read a target's owner so a projectile never hits its own caster.</summary>
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
/// <summary>RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).</summary>
ComponentLookup<KnockbackState> m_KnockbackLookup;
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
const float k_ProjectileRadius = 0.2f;
@@ -47,6 +50,7 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
state.RequireForUpdate<Projectile>();
@@ -56,6 +60,8 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state)
{
m_GhostOwnerLookup.Update(ref state);
m_KnockbackLookup.Update(ref state);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
float dt = SystemAPI.Time.DeltaTime;
var ecb = new EntityCommandBuffer(Allocator.Temp);
@@ -125,6 +131,16 @@ namespace ProjectM.Server
Amount = proj.ValueRO.Damage,
SourceNetworkId = projOwnerId,
});
var hitTarget = targetEntities[bestIdx];
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))
{
m_KnockbackLookup[hitTarget] = new KnockbackState
{
Dir = proj.ValueRO.Direction,
Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
};
}
ecb.DestroyEntity(projectileEntity);
continue;
}
@@ -0,0 +1,56 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative expiry of TIMED <see cref="StatModifier"/>s. Each tick it walks every entity's
/// server-only <see cref="TimedModifier"/> buffer; for any row whose <see cref="TimedModifier.UntilTick"/> has
/// elapsed (wrap-safe <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/> compare, never raw uint&lt;) it removes
/// the matching StatModifier(s) by SourceId and the timed row. The shortened StatModifier [GhostField] buffer
/// auto-replicates (OwnerSendType.All), so StatRecomputeSystem reverts the effective stat on both worlds with no
/// change. Runs in the plain server <see cref="SimulationSystemGroup"/> (NOT the predicted loop) so it is applied
/// exactly once and never double-removed on rollback; a DynamicBuffer mutation is not a structural change, so it
/// is safe to mutate the iterated entity's own buffers in place.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct TimedModifierExpirySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<TimedModifier>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
foreach (var (timed, mods) in
SystemAPI.Query<DynamicBuffer<TimedModifier>, DynamicBuffer<StatModifier>>())
{
for (int i = timed.Length - 1; i >= 0; i--)
{
uint until = timed[i].UntilTick;
if (until == 0)
continue; // inert (no expiry scheduled)
var untilTick = new NetworkTick(until);
if (untilTick.IsValid && untilTick.IsNewerThan(serverTick))
continue; // not yet due
TimedModifierUtil.RemoveBySourceId(mods, timed[i].SourceId);
timed.RemoveAtSwapBack(i);
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 94c6107954fa4d94f8ead51cfe4de3b7