LANTERN P1 Group B: 5-Spark systems — Aoe/Hitscan dispatch + reel/zone/decoy
Aoe/Hitscan archetype dispatch in AbilityFireSystem (both-worlds cooldown; server-only ecb.Instantiate — the client-never-instantiates invariant; ZoneEffect vs DecoyTag chosen by prefab membership). Harpooner REEL as a homing state (ReelState + ReelSystem re-aim the hit Husk's KnockbackState toward the caster's live position each tick; EnemyAISystem stays sole Position writer), stamped by ProjectileDamageSystem on a Reel-flag hit. ZonePulseSystem (enemy-only periodic AoE, caster attribution, no friendly fire, Vortex pull, born-0 lazy-stamp). Decoy aggro in EnemyAISystem + DecoySystem despawn. AbilityDefBlob gains EffectFlags (Reel) + DurationTicks. +3 ZonePulseSystem EditMode tests (497 green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY decoy-wisp lifecycle: despawns a decoy ghost when its Health is spent (Husks "killed" it) or
|
||||
/// its <see cref="DecoyTag.ExpireTick"/> lifetime elapses. Server-authoritative ghost despawn (the client's
|
||||
/// copy is removed by GhostSendSystem — never DestroyEntity a ghost client-side). The decoy draws aggro via
|
||||
/// <c>EnemyAISystem</c>'s decoy target set; this system only ends its life. Presence-gated on DecoyTag.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct DecoySystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<DecoyTag>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid) return;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (decoy, hp, e) in
|
||||
SystemAPI.Query<RefRO<DecoyTag>, RefRO<Health>>().WithEntityAccess())
|
||||
{
|
||||
bool expired = decoy.ValueRO.ExpireTick != 0u
|
||||
&& !new NetworkTick(decoy.ValueRO.ExpireTick).IsNewerThan(serverTick);
|
||||
if (hp.ValueRO.Current <= 0f || expired)
|
||||
ecb.DestroyEntity(e);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 355ca71da85a0f444bb9c312c4dc4643
|
||||
@@ -73,6 +73,17 @@ namespace ProjectM.Server
|
||||
structurePositions.Add(sx.ValueRO.Position);
|
||||
structureRegions.Add(sr.ValueRO.Region);
|
||||
}
|
||||
// LANTERN decoy-wisp aggro: a decoy draws Husks like a fake target. Region-agnostic gather (gym).
|
||||
var decoyEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var decoyPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (dx, dh, de) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
||||
.WithAll<DecoyTag>().WithEntityAccess())
|
||||
{
|
||||
if (dh.ValueRO.Current <= 0f) continue; // a spent decoy is being despawned; don't aggro a corpse
|
||||
decoyEntities.Add(de);
|
||||
decoyPositions.Add(dx.ValueRO.Position);
|
||||
}
|
||||
|
||||
// END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended
|
||||
// Husks march on the base heart (PlotCenter) so the base can be overrun instead of the swarm idling.
|
||||
@@ -80,7 +91,7 @@ namespace ProjectM.Server
|
||||
&& SystemAPI.TryGetSingleton<CoreIntegrity>(out var coreInteg) && coreInteg.Current > 0;
|
||||
float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>()) : float3.zero;
|
||||
|
||||
if (playerEntities.Length == 0 && structureEntities.Length == 0 && !coreAlive)
|
||||
if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0 && !coreAlive)
|
||||
{
|
||||
playerEntities.Dispose();
|
||||
playerPositions.Dispose();
|
||||
@@ -88,12 +99,15 @@ namespace ProjectM.Server
|
||||
structureEntities.Dispose();
|
||||
structurePositions.Dispose();
|
||||
structureRegions.Dispose();
|
||||
decoyEntities.Dispose();
|
||||
decoyPositions.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
float decoyAggroSq = 30f * 30f; // LANTERN decoy-wisp aggro radius (world units, squared)
|
||||
// Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies.
|
||||
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
||||
float structAggro = math.max(0f, tune.StructureAggroWeight);
|
||||
@@ -147,12 +161,30 @@ namespace ProjectM.Server
|
||||
// EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/
|
||||
// turret is the preferred target unless a player is in the way (closer after weighting).
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, huskRegion, structAggro, out bool tgtIsStruct, out int tgtIdx);
|
||||
if (tgtIdx < 0 && !huskCoreAlive)
|
||||
continue; // no player/structure and no Core -> nothing to seek
|
||||
Entity targetEntity = tgtIdx < 0 ? Entity.Null
|
||||
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
||||
float3 targetPos = tgtIdx < 0 ? corePos
|
||||
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]);
|
||||
// Decoy aggro (LANTERN): a decoy-wisp within range is the PREFERRED target -- overrides player/structure/core.
|
||||
int decoyIdx = -1;
|
||||
float decoyBestSq = decoyAggroSq;
|
||||
for (int di = 0; di < decoyPositions.Length; di++)
|
||||
{
|
||||
float dsq = math.distancesq(pos.xz, decoyPositions[di].xz);
|
||||
if (dsq <= decoyBestSq) { decoyBestSq = dsq; decoyIdx = di; }
|
||||
}
|
||||
Entity targetEntity;
|
||||
float3 targetPos;
|
||||
if (decoyIdx >= 0)
|
||||
{
|
||||
targetEntity = decoyEntities[decoyIdx];
|
||||
targetPos = decoyPositions[decoyIdx];
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tgtIdx < 0 && !huskCoreAlive)
|
||||
continue; // no decoy, no player/structure, and no Core -> nothing to seek
|
||||
targetEntity = tgtIdx < 0 ? Entity.Null
|
||||
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
||||
targetPos = tgtIdx < 0 ? corePos
|
||||
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]);
|
||||
}
|
||||
|
||||
// Seek: stop just inside strike range so the Husk holds position to attack.
|
||||
float stopDistance = stats.ValueRO.AttackRange * 0.9f;
|
||||
@@ -665,6 +697,8 @@ namespace ProjectM.Server
|
||||
structureEntities.Dispose();
|
||||
structurePositions.Dispose();
|
||||
structureRegions.Dispose();
|
||||
decoyEntities.Dispose();
|
||||
decoyPositions.Dispose();
|
||||
}
|
||||
|
||||
// Swept collide-and-slide for server-authoritative enemy movement — delegates to the shared
|
||||
|
||||
@@ -49,6 +49,9 @@ namespace ProjectM.Server
|
||||
/// <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>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.</summary>
|
||||
ComponentLookup<BossState> m_BossLookup;
|
||||
@@ -62,11 +65,18 @@ namespace ProjectM.Server
|
||||
/// <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_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
m_FxLookup = state.GetComponentLookup<ProjectileEffectState>(isReadOnly: false);
|
||||
|
||||
@@ -79,6 +89,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -165,17 +176,31 @@ namespace ProjectM.Server
|
||||
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
|
||||
});
|
||||
|
||||
// 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))
|
||||
// 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 pull = hasFx && (fx.Flags & ProjectileEffectFlag.Pull) != 0;
|
||||
float2 kdir = pull ? -proj.ValueRO.Direction : proj.ValueRO.Direction;
|
||||
m_KnockbackLookup[hitTarget] = new KnockbackState
|
||||
bool reel = hasFx && (fx.Flags & ProjectileEffectFlag.Reel) != 0;
|
||||
if (reel && m_ReelLookup.HasComponent(hitTarget))
|
||||
{
|
||||
Dir = kdir,
|
||||
Speed = Tuning.KnockbackSpeed,
|
||||
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
|
||||
};
|
||||
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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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-ONLY Harpooner reel driver. For every Husk carrying an active <see cref="ReelState"/> (stamped by
|
||||
/// <see cref="ProjectileDamageSystem"/> on a Reel-flagged HookPull hit), it re-aims the Husk's
|
||||
/// <see cref="KnockbackState"/> toward the CASTER's CURRENT position every tick — so the pull HOMES (unlike
|
||||
/// the frozen-Dir pull flag). <see cref="EnemyAISystem"/> then DRIVES that re-aimed knockback, staying the
|
||||
/// SOLE writer of the Husk's Position; this system writes only KnockbackState.Dir/Speed/UntilTick (never a
|
||||
/// position). Runs in the plain <see cref="SimulationSystemGroup"/> AFTER the predicted group (so THIS tick's
|
||||
/// stamp is visible) and BEFORE <see cref="EnemyAISystem"/>. Releases (clears both states) when the target is
|
||||
/// within release range, the leash (<see cref="ReelState.ExpireTick"/>) elapses, or the caster is gone.
|
||||
/// Bosses never reel (they don't carry KnockbackState/ReelState — knockback-immune, A4). Ticks via
|
||||
/// <see cref="TickUtil.NonZero"/>; compared with <see cref="NetworkTick"/> only.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(EnemyAISystem))]
|
||||
public partial struct ReelSystem : ISystem
|
||||
{
|
||||
const float k_ReleaseDistance = 1.6f; // stop reeling once the target is this close to the caster (world units)
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<ReelState>()));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid) return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
// Snapshot living players by NetworkId (<=4 in co-op; linear lookup is cheaper than a hashmap).
|
||||
var casterIds = new NativeList<int>(Allocator.Temp);
|
||||
var casterPos = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (owner, lt, hp) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>, RefRO<LocalTransform>, RefRO<Health>>().WithAll<PlayerTag>())
|
||||
{
|
||||
if (hp.ValueRO.Current <= 0f) continue;
|
||||
casterIds.Add(owner.ValueRO.NetworkId);
|
||||
casterPos.Add(lt.ValueRO.Position);
|
||||
}
|
||||
|
||||
float releaseSq = k_ReleaseDistance * k_ReleaseDistance;
|
||||
|
||||
foreach (var (reel, knockback, lt) in
|
||||
SystemAPI.Query<RefRW<ReelState>, RefRW<KnockbackState>, RefRO<LocalTransform>>()
|
||||
.WithAll<EnemyTag>().WithNone<Dying>())
|
||||
{
|
||||
uint expire = reel.ValueRO.ExpireTick;
|
||||
bool active = expire != 0u && new NetworkTick(expire).IsNewerThan(serverTick);
|
||||
if (!active)
|
||||
{
|
||||
// Leash elapsed (or never reeling): release so EnemyAISystem resumes seek next tick.
|
||||
if (expire != 0u) { reel.ValueRW.ExpireTick = 0u; knockback.ValueRW.UntilTick = 0u; }
|
||||
continue;
|
||||
}
|
||||
|
||||
int idx = -1;
|
||||
for (int i = 0; i < casterIds.Length; i++)
|
||||
if (casterIds[i] == reel.ValueRO.CasterNetworkId) { idx = i; break; }
|
||||
if (idx < 0) { reel.ValueRW.ExpireTick = 0u; knockback.ValueRW.UntilTick = 0u; continue; } // caster gone
|
||||
|
||||
float3 pos = lt.ValueRO.Position;
|
||||
float2 to = casterPos[idx].xz - pos.xz;
|
||||
if (math.lengthsq(to) <= releaseSq)
|
||||
{
|
||||
// Arrived at the caster: release (don't jitter on top of the player).
|
||||
reel.ValueRW.ExpireTick = 0u;
|
||||
knockback.ValueRW.UntilTick = 0u;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Home: re-aim the knockback toward the caster's LIVE position for exactly this tick; EnemyAISystem
|
||||
// applies it. Re-stamped every tick while reeling so it tracks the caster as they move.
|
||||
float2 dir = math.normalize(to);
|
||||
knockback.ValueRW.Dir = dir;
|
||||
knockback.ValueRW.Speed = reel.ValueRO.Speed;
|
||||
knockback.ValueRW.UntilTick = TickUtil.NonZero(now + 1u);
|
||||
}
|
||||
|
||||
casterIds.Dispose();
|
||||
casterPos.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 63b7300c466d5a945a7ce275769efea3
|
||||
@@ -0,0 +1,133 @@
|
||||
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-ONLY player-cast zone driver (Vortex / LightZone — the LANTERN Aoe/zone Sparks). Borrows the
|
||||
/// <see cref="GeyserEruptSystem"/> skeleton — the INVERTED invalid-tick guard (a born-0 <see cref="ZoneEffect.NextTick"/>
|
||||
/// is lazy-stamped born-correct, never storm-fires) and the <c>= now + period</c> reschedule — but is
|
||||
/// ENEMY-ONLY (NO friendly fire), stamps <see cref="DamageEvent.SourceNetworkId"/> = the caster's NetworkId
|
||||
/// (kills credit the caster — NOT the -1 environment convention), and has NO region gate (a no-world gym).
|
||||
/// A <see cref="ZoneEffectFlag.Vortex"/> zone ALSO writes a gentle inward <see cref="KnockbackState"/> every
|
||||
/// tick (EnemyAISystem drives it, sole Position writer), so runs BEFORE EnemyAISystem. DestroyEntity when
|
||||
/// <see cref="ZoneEffect.ExpireTick"/> elapses (server-authoritative ghost despawn). Ticks via
|
||||
/// <see cref="TickUtil.NonZero"/>; compared with <see cref="NetworkTick"/> only.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(EnemyAISystem))]
|
||||
public partial struct ZonePulseSystem : ISystem
|
||||
{
|
||||
const uint k_PulsePeriodTicks = 30u; // damage cadence (~0.5s at 60 ticks/sec)
|
||||
const float k_VortexPullSpeed = 4f; // gentle inward drift for a Vortex (world units/sec)
|
||||
const float k_VortexDeadzoneSq = 0.25f; // don't re-aim (or NaN) an enemy already at the zone centre
|
||||
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
ComponentLookup<BossState> m_BossLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<ZoneEffect>();
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid) return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
uint stamp = TickUtil.NonZero(now);
|
||||
uint reschedule = TickUtil.NonZero(now + k_PulsePeriodTicks);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
|
||||
// Living enemies once this tick (entities + positions; stable query order).
|
||||
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var enemyPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (lt, hp, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
||||
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
|
||||
{
|
||||
if (hp.ValueRO.Current <= 0f) continue;
|
||||
enemyEntities.Add(e);
|
||||
enemyPositions.Add(lt.ValueRO.Position);
|
||||
}
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (zone, lt, zoneEntity) in
|
||||
SystemAPI.Query<RefRW<ZoneEffect>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
var ze = zone.ValueRO;
|
||||
|
||||
// Lifetime: despawn the zone ghost when the leash elapses (server-authoritative).
|
||||
if (ze.ExpireTick != 0u && !new NetworkTick(ze.ExpireTick).IsNewerThan(serverTick))
|
||||
{
|
||||
ecb.DestroyEntity(zoneEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
float3 center = lt.ValueRO.Position;
|
||||
float radiusSq = ze.Radius * ze.Radius;
|
||||
bool vortex = (ze.Flags & ZoneEffectFlag.Vortex) != 0;
|
||||
|
||||
// Vortex pull: gentle inward drift EVERY tick (re-stamped, one tick; EnemyAISystem applies it).
|
||||
if (vortex)
|
||||
{
|
||||
for (int i = 0; i < enemyEntities.Length; i++)
|
||||
{
|
||||
float2 to = center.xz - enemyPositions[i].xz;
|
||||
float d2 = math.lengthsq(to);
|
||||
if (d2 > radiusSq || d2 <= k_VortexDeadzoneSq) continue;
|
||||
var e = enemyEntities[i];
|
||||
if (!m_KnockbackLookup.HasComponent(e) || m_BossLookup.HasComponent(e)) continue;
|
||||
m_KnockbackLookup[e] = new KnockbackState
|
||||
{
|
||||
Dir = math.normalize(to),
|
||||
Speed = k_VortexPullSpeed,
|
||||
UntilTick = TickUtil.NonZero(now + 1u),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Damage pulse: INVERTED invalid-tick guard (a born-0 NextTick lazy-stamps born-correct, never storms).
|
||||
uint next = ze.NextTick;
|
||||
if (next == 0u || !new NetworkTick(next).IsValid)
|
||||
{
|
||||
zone.ValueRW.NextTick = reschedule;
|
||||
continue;
|
||||
}
|
||||
if (new NetworkTick(next).IsNewerThan(serverTick)) continue; // still counting down
|
||||
|
||||
for (int i = 0; i < enemyEntities.Length; i++)
|
||||
{
|
||||
if (math.distancesq(enemyPositions[i].xz, center.xz) > radiusSq) continue;
|
||||
ecb.AppendToBuffer(enemyEntities[i], new DamageEvent
|
||||
{
|
||||
Amount = ze.DamagePerPulse,
|
||||
SourceNetworkId = ze.CasterNetworkId,
|
||||
SourceTick = stamp,
|
||||
});
|
||||
}
|
||||
zone.ValueRW.NextTick = reschedule;
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
enemyEntities.Dispose();
|
||||
enemyPositions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f96f56094eb9d4498f9e2da9d819e2a
|
||||
Reference in New Issue
Block a user