37b211a7f8
M1 — two systems timed INTERPOLATED ghosts against the PREDICTED tick, the exact hazard CLAUDE.md documents and the one that is invisible on loopback: - EnemyDangerTelegraphSystem timed the red danger cone off nt.ServerTick, so over a real connection the dodge tell finished ~RTT/2 + interp buffer EARLY. The cue lied. - PlayerAnimationDriveSystem fed the same predicted tick to RemoteDriveJob ([WithDisabled(GhostOwnerIsLocal)] — i.e. interpolated teammates), so a teammate's swing animation desynced from their damage. Both now use the ZoneTelegraphSystem idiom. The LOCAL drive job keeps ServerTick: the owning player really is predicted. M12 — the RPC leak I reproduced live during the audit. Every receiver in this project gates on RequireForUpdate over a scene-baked singleton; in a scene without it the receiver never runs and the request entity is never destroyed. Netcode's WarnAboutStaleRpcSystem Consume()s but never destroys, and is compiled out of player builds — so these accumulated silently, and worst in a shipped build. New StaleRpcReaperSystem (server, OrderLast, no RequireForUpdate) destroys any unconsumed request that outlived its receiving frame. Consumed requests are left to their owner. Verified live: a planted unconsumed request is gone within a few frames. Three regression tests pin both halves of the contract. Also: HealthApplyDamageSystem and ProjectileDamageSystem now filter .WithAll<Simulate>(). They are ServerSimulation-only so it was not a bug, but the audit's "all predicted systems filter Simulate" reassurance was false until now — the rule is unconditional again. The five undisposed Allocator.Temp ECBs the audit flagged all lived in systems the purge deleted; none remain. 298/298 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
296 lines
15 KiB
C#
296 lines
15 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// Server-authoritative projectile resolution: applies hits to damageable entities and expires
|
|
/// projectiles past their range. Runs in the server world only
|
|
/// (<see cref="WorldSystemFilterFlags.ServerSimulation"/>) inside the
|
|
/// <see cref="PredictedSimulationSystemGroup"/>, ordered <see cref="ProjectileMoveSystem"/> so the
|
|
/// projectile's <see cref="LocalTransform"/> is the post-move position for this tick.
|
|
///
|
|
/// Hit detection is a <b>swept</b> planar (XZ) test: rather than checking the projectile's point
|
|
/// position (which tunnels straight through a target when the per-tick step exceeds the target's
|
|
/// radius — e.g. a fast projectile, or any projectile while the server is tick-batching under load),
|
|
/// it reconstructs the segment the projectile traversed this tick
|
|
/// (<c>[curPos - dir*speed*dt, curPos]</c>) and tests each target's hit radius against the closest
|
|
/// point on that segment. The target hit earliest along the path (smallest segment parameter) wins.
|
|
/// A target whose <see cref="GhostOwner"/> matches the projectile's owner is skipped (no self-hits);
|
|
/// dummies carry no <see cref="GhostOwner"/> and are therefore always valid targets.
|
|
///
|
|
/// Phase 1.7 mechanic-changer boons ride the server-only <see cref="ProjectileEffectState"/> (seeded at
|
|
/// spawn by <c>AbilityFireSystem</c> from the owner's <see cref="BoonEffects"/>): PIERCE lets the projectile
|
|
/// survive a hit (decrement, don't destroy), CHAIN retargets its (replicated) <see cref="Projectile.Direction"/>
|
|
/// toward the next-nearest living enemy, and PULL flips the knockback heading toward the shooter. A per-projectile
|
|
/// hit-set is excluded DURING target selection so a surviving projectile never re-hits a target across ticks; the
|
|
/// set full (or no pierce/chain left) destroys as before. Projectiles WITHOUT the component behave exactly as
|
|
/// before (destroy on hit) — graceful degradation. Exactly ONE destroy per projectile per tick is preserved.
|
|
///
|
|
/// On a hit the system appends a <see cref="DamageEvent"/> to the target (consumed by
|
|
/// <c>HealthApplyDamageSystem</c>). Deferring damage to a buffer lets a single tick stack hits from multiple
|
|
/// projectiles. All structural changes go through an <see cref="EntityCommandBuffer"/> that plays back
|
|
/// immediately to the <see cref="EntityManager"/> (Temp allocator).
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
|
[UpdateAfter(typeof(ProjectileMoveSystem))]
|
|
public partial struct ProjectileDamageSystem : ISystem
|
|
{
|
|
/// <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>RW lookup to stamp the server-only homing ReelState on a Reel-flagged (HookPull) hit — the Harpooner reel.</summary>
|
|
ComponentLookup<ReelState> m_ReelLookup;
|
|
|
|
/// <summary>Knockback stamp lookup. The former BossState immunity gate went with the boss purge
|
|
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
|
|
|
|
/// <summary>RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set.</summary>
|
|
ComponentLookup<ProjectileEffectState> m_FxLookup;
|
|
|
|
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
|
|
const float k_ProjectileRadius = 0.2f;
|
|
|
|
/// <summary>Max planar distance a Ricochet chain will reach for its next target (tunable).</summary>
|
|
const float k_ChainRange = 8f;
|
|
|
|
/// <summary>Harpooner reel pull speed (world units/sec) written into the reeled Husk's KnockbackState.</summary>
|
|
const float k_ReelSpeed = 16f;
|
|
|
|
/// <summary>Reel leash: max ticks the homing pull lasts before releasing (~60 ticks/sec).</summary>
|
|
const uint k_ReelLeashTicks = 90u;
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
|
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
|
m_ReelLookup = state.GetComponentLookup<ReelState>(isReadOnly: false);
|
|
|
|
m_FxLookup = state.GetComponentLookup<ProjectileEffectState>(isReadOnly: false);
|
|
|
|
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
|
|
state.RequireForUpdate<Projectile>();
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
m_GhostOwnerLookup.Update(ref state);
|
|
m_KnockbackLookup.Update(ref state);
|
|
m_ReelLookup.Update(ref state);
|
|
|
|
m_FxLookup.Update(ref state);
|
|
|
|
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
|
|
|
float dt = SystemAPI.Time.DeltaTime;
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
|
|
// Snapshot all damageable targets once for this tick. Stable iteration order (query order).
|
|
var targetEntities = new NativeList<Entity>(Allocator.Temp);
|
|
var targetPositions = new NativeList<float3>(Allocator.Temp);
|
|
var targetRadii = new NativeList<float>(Allocator.Temp);
|
|
|
|
foreach (var (xform, hitRadius, targetEntity) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
|
|
.WithAll<Health>()
|
|
.WithNone<Dying>() // B3: corpses are not shields - a shot passes through to living targets
|
|
.WithEntityAccess())
|
|
{
|
|
targetEntities.Add(targetEntity);
|
|
targetPositions.Add(xform.ValueRO.Position);
|
|
targetRadii.Add(hitRadius.ValueRO.Value);
|
|
}
|
|
|
|
foreach (var (xform, proj, owner, projectileEntity) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRW<Projectile>, RefRO<GhostOwner>>()
|
|
.WithAll<Simulate>() // predicted-group convention: only simulate what this tick simulates
|
|
.WithEntityAccess())
|
|
{
|
|
int projOwnerId = owner.ValueRO.NetworkId;
|
|
|
|
bool hasFx = m_FxLookup.HasComponent(projectileEntity);
|
|
ProjectileEffectState fx = hasFx ? m_FxLookup[projectileEntity] : default;
|
|
|
|
// This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather
|
|
// than testing only segEnd) is what prevents fast projectiles from tunnelling targets.
|
|
float3 cur = xform.ValueRO.Position;
|
|
float2 segEnd = new float2(cur.x, cur.z);
|
|
float2 segStart = segEnd - proj.ValueRO.Direction * (proj.ValueRO.Speed * dt);
|
|
float2 seg = segEnd - segStart;
|
|
float segLenSq = math.lengthsq(seg);
|
|
|
|
int bestIdx = -1;
|
|
float bestT = float.MaxValue;
|
|
for (int i = 0; i < targetEntities.Length; i++)
|
|
{
|
|
var target = targetEntities[i];
|
|
|
|
// Skip the caster: a target whose GhostOwner matches the projectile owner is the
|
|
// shooter (or another ghost they own). Dummies have no GhostOwner, so never skipped.
|
|
if (m_GhostOwnerLookup.HasComponent(target) &&
|
|
m_GhostOwnerLookup[target].NetworkId == projOwnerId)
|
|
continue;
|
|
|
|
// Phase 1.7: a surviving (pierced/chained) projectile never re-hits a target already struck —
|
|
// excluded DURING selection, not post-filtered.
|
|
if (hasFx && HitSetContains(in fx, target))
|
|
continue;
|
|
|
|
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
|
|
|
// Closest point on the travel segment to the target centre.
|
|
float t = segLenSq > 1e-8f
|
|
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
|
|
: 0f;
|
|
float2 closest = segStart + t * seg;
|
|
|
|
float hitDist = targetRadii[i] + k_ProjectileRadius;
|
|
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
|
|
{
|
|
bestT = t;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
|
|
if (bestIdx >= 0)
|
|
{
|
|
var hitTarget = targetEntities[bestIdx];
|
|
|
|
// Earliest target along the travel path: deal damage.
|
|
ecb.AppendToBuffer(hitTarget, new DamageEvent
|
|
{
|
|
Amount = proj.ValueRO.Damage,
|
|
SourceNetworkId = projOwnerId,
|
|
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
|
|
});
|
|
|
|
// Knockback / REEL. Reel (HookPull) stamps the HOMING ReelState (ReelSystem re-aims toward the
|
|
// caster's live position each tick); otherwise the classic frozen knockback (PULL flips toward the shooter).
|
|
if (haveTick && m_KnockbackLookup.HasComponent(hitTarget))
|
|
{
|
|
bool reel = hasFx && (fx.Flags & ProjectileEffectFlag.Reel) != 0;
|
|
if (reel && m_ReelLookup.HasComponent(hitTarget))
|
|
{
|
|
m_ReelLookup[hitTarget] = new ReelState
|
|
{
|
|
CasterNetworkId = projOwnerId,
|
|
Speed = k_ReelSpeed,
|
|
ExpireTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + k_ReelLeashTicks),
|
|
};
|
|
}
|
|
else if (Tuning.KnockbackSpeed > 0f)
|
|
{
|
|
bool pull = hasFx && (fx.Flags & ProjectileEffectFlag.Pull) != 0;
|
|
float2 kdir = pull ? -proj.ValueRO.Direction : proj.ValueRO.Direction;
|
|
m_KnockbackLookup[hitTarget] = new KnockbackState
|
|
{
|
|
Dir = kdir,
|
|
Speed = Tuning.KnockbackSpeed,
|
|
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Phase 1.7: pierce/chain let the projectile SURVIVE; else it is consumed. Record the target so it
|
|
// can't be re-hit. A full hit-set is a natural cap → destroy.
|
|
bool survive = false;
|
|
if (hasFx)
|
|
{
|
|
if (fx.Hit.Length < fx.Hit.Capacity)
|
|
{
|
|
fx.Hit.Add(hitTarget);
|
|
if (fx.PierceRemaining > 0)
|
|
{
|
|
fx.PierceRemaining = (byte)(fx.PierceRemaining - 1);
|
|
survive = true;
|
|
}
|
|
else if (fx.ChainRemaining > 0)
|
|
{
|
|
int nextIdx = FindChainTarget(targetEntities, targetPositions, cur, projOwnerId, in fx);
|
|
if (nextIdx >= 0)
|
|
{
|
|
float2 to = new float2(targetPositions[nextIdx].x - cur.x, targetPositions[nextIdx].z - cur.z);
|
|
if (math.lengthsq(to) > 1e-6f)
|
|
{
|
|
proj.ValueRW.Direction = math.normalize(to);
|
|
fx.ChainRemaining = (byte)(fx.ChainRemaining - 1);
|
|
survive = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
m_FxLookup[projectileEntity] = fx;
|
|
}
|
|
|
|
if (survive)
|
|
{
|
|
// A surviving projectile still expires once it has travelled its full range.
|
|
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range)
|
|
ecb.DestroyEntity(projectileEntity);
|
|
continue;
|
|
}
|
|
|
|
ecb.DestroyEntity(projectileEntity);
|
|
continue;
|
|
}
|
|
|
|
// Nothing hit this tick: expire the projectile once it has travelled its full range.
|
|
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range)
|
|
ecb.DestroyEntity(projectileEntity);
|
|
}
|
|
|
|
ecb.Playback(state.EntityManager);
|
|
|
|
ecb.Dispose();
|
|
targetEntities.Dispose();
|
|
targetPositions.Dispose();
|
|
targetRadii.Dispose();
|
|
}
|
|
|
|
/// <summary>True when <paramref name="target"/> is already in the projectile's re-hit history.</summary>
|
|
static bool HitSetContains(in ProjectileEffectState fx, Entity target)
|
|
{
|
|
for (int i = 0; i < fx.Hit.Length; i++)
|
|
if (fx.Hit[i] == target) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Nearest living target to <paramref name="from"/> within <see cref="k_ChainRange"/> that is neither
|
|
/// the caster's own ghost nor already in the projectile's hit-set. Returns the snapshot index or -1.</summary>
|
|
int FindChainTarget(in NativeList<Entity> targetEntities, in NativeList<float3> targetPositions,
|
|
float3 from, int projOwnerId, in ProjectileEffectState fx)
|
|
{
|
|
int best = -1;
|
|
float bestDistSq = k_ChainRange * k_ChainRange;
|
|
for (int i = 0; i < targetEntities.Length; i++)
|
|
{
|
|
var target = targetEntities[i];
|
|
if (m_GhostOwnerLookup.HasComponent(target) && m_GhostOwnerLookup[target].NetworkId == projOwnerId)
|
|
continue;
|
|
if (HitSetContains(in fx, target))
|
|
continue;
|
|
float2 d = new float2(targetPositions[i].x - from.x, targetPositions[i].z - from.z);
|
|
float dsq = math.lengthsq(d);
|
|
if (dsq <= bestDistSq)
|
|
{
|
|
bestDistSq = dsq;
|
|
best = i;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
}
|
|
}
|