Attack Boon Changes

This commit is contained in:
2026-07-13 18:30:41 -07:00
parent 972e0d5b4f
commit 24800f4bcb
34 changed files with 1306 additions and 112 deletions
@@ -118,6 +118,30 @@ namespace ProjectM.Server
if (idx < 0)
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
if (pool.Defs[idx].Kind == 1)
{
// Phase 1.7 mechanic-changer: mutate the baked-present BoonEffects (non-structural) instead of
// appending a StatModifier. Bytes only (Burst-safe switch). No BoonPickCounter bump (no band row).
if (!state.EntityManager.HasComponent<BoonEffects>(player))
return false; // real players are baked with it; skip defensively otherwise
var fx = state.EntityManager.GetComponentData<BoonEffects>(player);
byte delta = (byte)pool.Defs[idx].Value;
switch (pool.Defs[idx].EffectKind)
{
case BoonEffectKind.Pierce: fx.Pierce = (byte)(fx.Pierce + delta); break;
case BoonEffectKind.Fork: fx.Fork = (byte)(fx.Fork + delta); break;
case BoonEffectKind.Chain: fx.Chain = (byte)(fx.Chain + delta); break;
case BoonEffectKind.DashTrail: fx.Flags |= BoonFlag.DashTrail; break;
case BoonEffectKind.FinisherDetonate: fx.Flags |= BoonFlag.FinisherDetonate; break;
case BoonEffectKind.KnockToPull: fx.Flags |= BoonFlag.KnockToPull; break;
case BoonEffectKind.Siphon: fx.Flags |= BoonFlag.Siphon; break;
case BoonEffectKind.Frenzy: fx.Flags |= BoonFlag.Frenzy; break;
default: return false; // unknown effect kind — preserve-and-skip
}
state.EntityManager.SetComponentData(player, fx);
return true;
}
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
mods.Add(new StatModifier
{
@@ -58,16 +58,16 @@ namespace ProjectM.Server
return;
ref var pool = ref catalog.Value.Value;
foreach (var (offer, owner, region, cls) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>>()
foreach (var (offer, owner, region, cls, fx) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>, RefRO<BoonEffects>>()
.WithAll<PlayerTag>())
{
if (region.ValueRO.Region != RegionId.Expedition)
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room).
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room, player, owned-effects-at-draw).
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, ref pool, out byte o0, out byte o1, out byte o2);
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, fx.ValueRO, ref pool, out byte o0, out byte o1, out byte o2);
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
}
@@ -0,0 +1,139 @@
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>
/// Phase 1.7 "Blade Dash" boon (<see cref="BoonFlag.DashTrail"/>): while a player is inside its dash blink window,
/// living enemies within <see cref="k_Radius"/> of the player take damage — one hit per enemy per dash. SERVER-ONLY
/// (enemies are interpolated ghosts the client never predicts — mirrors the melee cleave / cone / projectile-damage
/// pattern), inside the predicted group after <see cref="DashSystem"/> (dash state committed) and before
/// <c>HealthApplyDamageSystem</c> (the DamageEvent drains the same tick). Enemies carry no <c>DashState</c>, so the
/// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless.
///
/// Dedup is keyed to <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and has
/// NO reliable clear edge on a release server): <see cref="DashTrailState.Hit"/> is cleared whenever the current
/// StartTick differs from <see cref="DashTrailState.LastStartTick"/>. Server-only ⇒ no rollback, so persisting the
/// accumulator across ticks is safe. A per-tick radius test (run every blink tick) approximates the swept path; the
/// per-tick dash step (&lt;~0.6u) is well inside the radius, so a thin enemy is not tunnelled. Hit-set overflow stops
/// adding (a possible re-hit on a very crowded dash — accepted v1 cap).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(DashSystem))]
[UpdateBefore(typeof(HealthApplyDamageSystem))]
public partial struct DashTrailDamageSystem : ISystem
{
const float k_Radius = 1.6f; // planar hit radius around the dashing player (tunable)
const float k_Damage = 12f; // per-enemy damage for a dash pass (tunable)
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<DashTrailState>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var nt = SystemAPI.GetSingleton<NetworkTime>();
var serverTick = nt.ServerTick;
if (!serverTick.IsValid)
return;
// Snapshot living enemies once (positions + radii + entities), stable query order.
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
var enemyPositions = new NativeList<float3>(Allocator.Temp);
var enemyRadii = new NativeList<float>(Allocator.Temp);
foreach (var (tx, hr, hp, te) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>>()
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
{
if (hp.ValueRO.Current <= 0f) continue;
enemyEntities.Add(te);
enemyPositions.Add(tx.ValueRO.Position);
enemyRadii.Add(hr.ValueRO.Value);
}
if (enemyEntities.Length == 0)
{
enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose();
return;
}
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, dash, trail, owner, fx) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<DashState>, RefRW<DashTrailState>,
RefRO<GhostOwner>, RefRO<BoonEffects>>()
.WithAll<PlayerTag, Simulate>())
{
if ((fx.ValueRO.Flags & BoonFlag.DashTrail) == 0)
continue;
uint startRaw = dash.ValueRO.StartTick;
if (startRaw == 0u)
continue; // never dashed
// Inside the blink (i-frame) window [StartTick, IFrameUntilTick)?
var startTick = new NetworkTick(startRaw);
var untilTick = new NetworkTick(dash.ValueRO.IFrameUntilTick);
bool dashing = startTick.IsValid && untilTick.IsValid
&& !startTick.IsNewerThan(serverTick) && untilTick.IsNewerThan(serverTick);
if (!dashing)
continue;
// New dash → reset the per-dash hit set (StartTick changes every dash; no reliable DashState clear).
if (trail.ValueRO.LastStartTick != startRaw)
{
trail.ValueRW.Hit.Clear();
trail.ValueRW.LastStartTick = startRaw;
}
float3 p = xform.ValueRO.Position;
int ownerId = owner.ValueRO.NetworkId;
for (int i = 0; i < enemyEntities.Length; i++)
{
var enemy = enemyEntities[i];
if (HitContains(trail.ValueRO, enemy))
continue;
float2 d = new float2(enemyPositions[i].x - p.x, enemyPositions[i].z - p.z);
float reach = k_Radius + enemyRadii[i];
if (math.lengthsq(d) > reach * reach)
continue;
if (trail.ValueRO.Hit.Length >= trail.ValueRO.Hit.Capacity) break; // hit-cap: never damage an enemy we can't record (else re-hit every tick)
ecb.AppendToBuffer(enemy, new DamageEvent
{
Amount = k_Damage,
SourceNetworkId = ownerId, // a real player id (legit Charger whiff-punish credit)
SourceTick = stamp,
});
if (trail.ValueRO.Hit.Length < trail.ValueRO.Hit.Capacity)
trail.ValueRW.Hit.Add(enemy);
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
enemyEntities.Dispose();
enemyPositions.Dispose();
enemyRadii.Dispose();
}
static bool HitContains(in DashTrailState trail, Entity e)
{
for (int i = 0; i < trail.Hit.Length; i++)
if (trail.Hit[i] == e) return true;
return false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2a00802a81103745a1d20475a3c7b7b
@@ -77,6 +77,7 @@ namespace ProjectM.Server
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
uint negatedForThisEntity = 0u;
float total = 0f;
int killerNetId = -1; // Phase 1.7: last player-sourced (non-negated) hit this tick → on-kill boon credit
for (int i = 0; i < dmg.Length; i++)
{
uint src = dmg[i].SourceTick;
@@ -96,6 +97,7 @@ namespace ProjectM.Server
}
}
total += dmg[i].Amount;
if (dmg[i].SourceNetworkId >= 0) killerNetId = dmg[i].SourceNetworkId; // Phase 1.7 kill credit
// 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.
@@ -149,6 +151,8 @@ namespace ProjectM.Server
ecb.AddComponent(entity, new Dying
{
UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks),
KillerNetId = killerNetId, // Phase 1.7: KillRewardSystem reads this for Siphon/Frenzy
Rewarded = 0,
});
if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup));
if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState));
@@ -0,0 +1,104 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Phase 1.7 on-kill boons. When <c>HealthApplyDamageSystem</c> stamps an enemy <see cref="Dying"/> it records the
/// crediting player's NetworkId; this system grants that killer their on-kill boons ONCE per corpse:
/// <see cref="BoonFlag.Siphon"/> heals the killer (clamped to <see cref="EffectiveCharacterStats.MaxHealth"/>) and
/// <see cref="BoonFlag.Frenzy"/> refreshes a short cooldown-reduction buff (<see cref="TimedModifierUtil.Upsert"/> —
/// re-stamped, never stacked). Idempotent via the <see cref="Dying.Rewarded"/> latch (a value write, no edge-detect).
///
/// A SEPARATE system (not folded into HealthApplyDamageSystem) because healing the killer needs RW
/// <see cref="Health"/> access, which would alias that system's <c>RefRW&lt;Health&gt;</c> victim query. Here the
/// only query is <c>RefRW&lt;Dying&gt;</c> over enemies, and all killer writes go through ComponentLookup/BufferLookup
/// on player entities — no aliasing. Server-only (no rollback) inside the predicted group, after damage application.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(HealthApplyDamageSystem))]
public partial struct KillRewardSystem : ISystem
{
ComponentLookup<BoonEffects> m_Fx;
ComponentLookup<Health> m_Health;
ComponentLookup<EffectiveCharacterStats> m_EffChar;
BufferLookup<StatModifier> m_Mods;
BufferLookup<TimedModifier> m_Timed;
const float k_SiphonHeal = 8f; // HP restored per kill (tunable)
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_Fx = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
m_Health = state.GetComponentLookup<Health>(isReadOnly: false);
m_EffChar = state.GetComponentLookup<EffectiveCharacterStats>(isReadOnly: true);
m_Mods = state.GetBufferLookup<StatModifier>(isReadOnly: false);
m_Timed = state.GetBufferLookup<TimedModifier>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<Dying>(); // only run while a fresh corpse exists
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
m_Fx.Update(ref state);
m_Health.Update(ref state);
m_EffChar.Update(ref state);
m_Mods.Update(ref state);
m_Timed.Update(ref state);
// Resolve killers by NetworkId (players only).
var playerByNet = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
playerByNet[owner.ValueRO.NetworkId] = e;
uint until = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.FrenzyDurationTicks));
foreach (var (dying, corpse) in SystemAPI.Query<RefRW<Dying>>().WithAll<EnemyTag>().WithEntityAccess())
{
if (dying.ValueRO.Rewarded != 0)
continue;
dying.ValueRW.Rewarded = 1; // mark ONCE — idempotent even when the killer can't be resolved
int killerNet = dying.ValueRO.KillerNetId;
if (killerNet < 0 || !playerByNet.TryGetValue(killerNet, out var killer))
continue;
if (!m_Fx.HasComponent(killer))
continue;
byte flags = m_Fx[killer].Flags;
// Siphon: heal the killer, clamped to their effective max (no over-heal; skip a corpse killer).
if ((flags & BoonFlag.Siphon) != 0 && m_Health.HasComponent(killer))
{
var h = m_Health[killer];
if (h.Current > 0f)
{
float max = m_EffChar.HasComponent(killer) ? m_EffChar[killer].MaxHealth : h.Max;
h.Current = math.min(h.Current + k_SiphonHeal, max);
m_Health[killer] = h;
}
}
// Frenzy: refresh (never stack) a short cooldown-reduction buff on the killer.
if ((flags & BoonFlag.Frenzy) != 0 && m_Mods.HasBuffer(killer) && m_Timed.HasBuffer(killer))
{
TimedModifierUtil.Upsert(m_Mods[killer], m_Timed[killer], Tuning.FrenzySourceId,
(byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, Tuning.FrenzyCooldownMult, until);
}
}
playerByNet.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43348399863cc454a8752cce54cc329d
@@ -24,12 +24,18 @@ namespace ProjectM.Server
/// 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>) and destroys the projectile. 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) — keeping this server-only, once-per-tick system
/// self-contained and plain-world testable without a separate ECB system.
/// <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)]
@@ -47,17 +53,22 @@ namespace ProjectM.Server
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
ComponentLookup<BossState> m_BossLookup;
/// <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;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
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>();
@@ -69,6 +80,7 @@ namespace ProjectM.Server
m_GhostOwnerLookup.Update(ref state);
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
m_FxLookup.Update(ref state);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
@@ -92,11 +104,14 @@ namespace ProjectM.Server
}
foreach (var (xform, proj, owner, projectileEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>, RefRO<GhostOwner>>()
SystemAPI.Query<RefRO<LocalTransform>, RefRW<Projectile>, RefRO<GhostOwner>>()
.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;
@@ -117,6 +132,11 @@ namespace ProjectM.Server
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.
@@ -135,23 +155,68 @@ namespace ProjectM.Server
if (bestIdx >= 0)
{
// Earliest target along the travel path: deal damage and consume the projectile.
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
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,
});
var hitTarget = targetEntities[bestIdx];
// Knockback (Phase 1.7: PULL flips the heading toward the shooter when the owner's boon is set).
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
{
bool pull = hasFx && (fx.Flags & ProjectileEffectFlag.Pull) != 0;
float2 kdir = pull ? -proj.ValueRO.Direction : proj.ValueRO.Direction;
m_KnockbackLookup[hitTarget] = new KnockbackState
{
Dir = proj.ValueRO.Direction,
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;
}
@@ -168,5 +233,38 @@ namespace ProjectM.Server
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;
}
}
}