Attack Boon Changes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user