using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
///
/// Server-authoritative projectile resolution: applies hits to damageable entities and expires
/// projectiles past their range. Runs in the server world only
/// () inside the
/// , ordered so the
/// projectile's is the post-move position for this tick.
///
/// Hit detection is a swept 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
/// ([curPos - dir*speed*dt, curPos]) 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 matches the projectile's owner is skipped (no self-hits);
/// dummies carry no and are therefore always valid targets.
///
/// Phase 1.7 mechanic-changer boons ride the server-only (seeded at
/// spawn by AbilityFireSystem from the owner's ): PIERCE lets the projectile
/// survive a hit (decrement, don't destroy), CHAIN retargets its (replicated)
/// 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 to the target (consumed by
/// HealthApplyDamageSystem). Deferring damage to a buffer lets a single tick stack hits from multiple
/// projectiles. All structural changes go through an that plays back
/// immediately to the (Temp allocator).
///
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(ProjectileMoveSystem))]
public partial struct ProjectileDamageSystem : ISystem
{
/// Lookup used to read a target's owner so a projectile never hits its own caster.
ComponentLookup m_GhostOwnerLookup;
/// RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).
ComponentLookup m_KnockbackLookup;
/// RW lookup to stamp the server-only homing ReelState on a Reel-flagged (HookPull) hit — the Harpooner reel.
ComponentLookup m_ReelLookup;
/// Read-only lookup so a BOSS (BossState) is skipped by the knockback stamp — the boss is
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.
ComponentLookup m_BossLookup;
/// RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set.
ComponentLookup m_FxLookup;
/// Extra forgiveness added to a target's hit radius to approximate the projectile's own size.
const float k_ProjectileRadius = 0.2f;
/// Max planar distance a Ricochet chain will reach for its next target (tunable).
const float k_ChainRange = 8f;
/// Harpooner reel pull speed (world units/sec) written into the reeled Husk's KnockbackState.
const float k_ReelSpeed = 16f;
/// Reel leash: max ticks the homing pull lasts before releasing (~60 ticks/sec).
const uint k_ReelLeashTicks = 90u;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_GhostOwnerLookup = state.GetComponentLookup(isReadOnly: true);
m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false);
m_ReelLookup = state.GetComponentLookup(isReadOnly: false);
m_BossLookup = state.GetComponentLookup(isReadOnly: true);
m_FxLookup = state.GetComponentLookup(isReadOnly: false);
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
state.RequireForUpdate();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
m_GhostOwnerLookup.Update(ref state);
m_KnockbackLookup.Update(ref state);
m_ReelLookup.Update(ref state);
m_BossLookup.Update(ref state);
m_FxLookup.Update(ref state);
bool haveTick = SystemAPI.TryGetSingleton(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(Allocator.Temp);
var targetPositions = new NativeList(Allocator.Temp);
var targetRadii = new NativeList(Allocator.Temp);
foreach (var (xform, hitRadius, targetEntity) in
SystemAPI.Query, RefRO>()
.WithAll()
.WithNone() // 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, RefRW, RefRO>()
.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) && !m_BossLookup.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();
}
/// True when is already in the projectile's re-hit history.
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;
}
/// Nearest living target to within that is neither
/// the caster's own ghost nor already in the projectile's hit-set. Returns the snapshot index or -1.
int FindChainTarget(in NativeList targetEntities, in NativeList 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;
}
}
}