From 36950ca21deb837ec427e05f85ed1b2d3fe3da9a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Jul 2026 00:00:41 -0700 Subject: [PATCH] =?UTF-8?q?LANTERN=20P1=20Group=20B:=205-Spark=20systems?= =?UTF-8?q?=20=E2=80=94=20Aoe/Hitscan=20dispatch=20+=20reel/zone/decoy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Combat/AbilityDatabaseAuthoring.cs | 2 + .../Authoring/Combat/AbilityDefinition.cs | 5 + .../Authoring/Combat/DecoyAuthoring.cs | 31 ++++ .../Authoring/Combat/DecoyAuthoring.cs.meta | 2 + .../Authoring/Combat/EnemyAuthoring.cs | 1 + .../Scripts/Authoring/Combat/ZoneAuthoring.cs | 34 +++++ .../Authoring/Combat/ZoneAuthoring.cs.meta | 2 + .../Scripts/Server/Combat/DecoySystem.cs | 45 ++++++ .../Scripts/Server/Combat/DecoySystem.cs.meta | 2 + .../Scripts/Server/Combat/EnemyAISystem.cs | 48 ++++++- .../Server/Combat/ProjectileDamageSystem.cs | 43 ++++-- .../Scripts/Server/Combat/ReelSystem.cs | 99 +++++++++++++ .../Scripts/Server/Combat/ReelSystem.cs.meta | 2 + .../Scripts/Server/Combat/ZonePulseSystem.cs | 133 ++++++++++++++++++ .../Server/Combat/ZonePulseSystem.cs.meta | 2 + .../Simulation/Combat/AbilityDatabaseBlob.cs | 2 + .../Simulation/Combat/AbilityFireSystem.cs | 88 +++++++++++- .../Combat/ProjectileEffectState.cs | 2 + .../Scripts/Simulation/Combat/ReelState.cs | 28 ++++ .../Simulation/Combat/ReelState.cs.meta | 2 + .../Combat/SparkEffectComponents.cs | 54 +++++++ .../Combat/SparkEffectComponents.cs.meta | 2 + .../Tests/EditMode/ZonePulseSystemTests.cs | 127 +++++++++++++++++ .../EditMode/ZonePulseSystemTests.cs.meta | 2 + 24 files changed, 738 insertions(+), 20 deletions(-) create mode 100644 Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/DecoySystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/DecoySystem.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/ReelSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/ReelSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ReelState.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ReelState.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs create mode 100644 Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs.meta diff --git a/Assets/_Project/Scripts/Authoring/Combat/AbilityDatabaseAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/AbilityDatabaseAuthoring.cs index 295529497..6f681124c 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/AbilityDatabaseAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/AbilityDatabaseAuthoring.cs @@ -50,6 +50,8 @@ namespace ProjectM.Authoring Archetype = (byte)def.Archetype, AutoTargetConeRadians = math.radians(def.AutoTargetConeDegrees), CooldownTicks = def.CooldownTicks, + EffectFlags = (byte)(def.Reel ? ProjectileEffectFlag.Reel : (byte)0), + DurationTicks = def.EffectDurationTicks, Name = def.DisplayName, }; } diff --git a/Assets/_Project/Scripts/Authoring/Combat/AbilityDefinition.cs b/Assets/_Project/Scripts/Authoring/Combat/AbilityDefinition.cs index c66ecf6a3..5629e9211 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/AbilityDefinition.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/AbilityDefinition.cs @@ -28,6 +28,11 @@ namespace ProjectM.Authoring [Header("Timing")] [Min(1)] public int CooldownTicks = 12; + [Header("Spark effect (LANTERN)")] + [Tooltip("HookPull: home the hit target to the caster (the Harpooner reel).")] + public bool Reel = false; + [Min(0), Tooltip("Aoe/zone + decoy lifetime in ticks (0 = system default).")] + public int EffectDurationTicks = 0; [Header("Prefab (baked into the prefab buffer, not the blob)")] public GameObject ProjectilePrefab; diff --git a/Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs new file mode 100644 index 000000000..ac55350bf --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs @@ -0,0 +1,31 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for a decoy-wisp ghost (the LANTERN Aoe/spawn Spark). Bakes an inert + /// (lifetime stamped at spawn) plus the damageable set (Health + DamageEvent buffer) so Husks path to and + /// "kill" it — drawing aggro off the players (EnemyAISystem targets DecoyTag entities). NO HitRadius: the + /// decoy is NOT a projectile target, so the caster's own shots pass through it. DecoySystem despawns + /// it on Health <= 0 or lifetime elapse. Ghost setup (interpolated, ownerless) is inherited by duplicating + /// an existing ownerless interpolated ghost, so position replicates via the stock LocalTransform variant. + /// + public class DecoyAuthoring : MonoBehaviour + { + [Min(1f), Tooltip("Decoy hit points — how much aggro/melee it soaks before popping.")] + public float MaxHealth = 60f; + + private class DecoyBaker : Baker + { + public override void Bake(DecoyAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); + AddComponent(entity); // ExpireTick stamped at spawn by AbilityFireSystem + AddComponent(entity, new Health { Current = authoring.MaxHealth, Max = authoring.MaxHealth }); + AddBuffer(entity); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs.meta new file mode 100644 index 000000000..2238f4d95 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/DecoyAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fbd3e612f2e1e0a4abbd5d665943e9f2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs index ce1d6d697..8e611bfc5 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs @@ -51,6 +51,7 @@ namespace ProjectM.Authoring }); AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 }); AddComponent(entity); // server-only recoil state (zero = not knocked) + AddComponent(entity); // server-only Harpooner-reel homing state (baked inert; stamped on a Reel-flag hit; NOT a GhostField) AddComponent(entity, new EnemyNavState { LastPos = float.MaxValue }); // server-only anti-stuck nav state (not replicated); sentinel LastPos forces a first-tick reset AddComponent(entity); // replicated telegraph signal (zero = not winding up) // Slice 1 (Feature C): client-safe baked telegraph metadata. EnemyBaker is the SOLE writer of diff --git a/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs new file mode 100644 index 000000000..b66365725 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs @@ -0,0 +1,34 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for a player-cast ZONE ghost (Vortex / LightZone — the LANTERN Aoe/zone Sparks). Bakes an inert + /// ; the runtime values (caster / radius / damage / lifetime) are stamped at spawn by + /// AbilityFireSystem — only the Vortex FLAG is authored here (so AbilityFireSystem can pick + /// ZoneEffect vs DecoyTag by prefab membership, and the pull behaviour is baked-in). The prefab's ghost setup + /// (GhostAuthoringComponent: interpolated, ownerless) is inherited by DUPLICATING an existing ownerless + /// interpolated ghost, so it replicates to all clients via the stock LocalTransform variant (no hand-written + /// [GhostField]). GetEntity(Dynamic) gives a runtime-mutable LocalTransform for the spawn override. + /// + public class ZoneAuthoring : MonoBehaviour + { + [Tooltip("Vortex: pull enemies toward the zone centre each tick (in addition to the periodic damage).")] + public bool Vortex = false; + + private class ZoneBaker : Baker + { + public override void Bake(ZoneAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); + AddComponent(entity, new ZoneEffect + { + Flags = authoring.Vortex ? ZoneEffectFlag.Vortex : (byte)0, + // caster / radius / damage / NextTick / ExpireTick are seeded at spawn by AbilityFireSystem. + }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs.meta new file mode 100644 index 000000000..f77a04b9e --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 491130dca5b97114cac9f21582092329 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/DecoySystem.cs b/Assets/_Project/Scripts/Server/Combat/DecoySystem.cs new file mode 100644 index 000000000..ddb3b1b8e --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/DecoySystem.cs @@ -0,0 +1,45 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// SERVER-ONLY decoy-wisp lifecycle: despawns a decoy ghost when its Health is spent (Husks "killed" it) or + /// its 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 + /// EnemyAISystem's decoy target set; this system only ends its life. Presence-gated on DecoyTag. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + public partial struct DecoySystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) return; + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (decoy, hp, e) in + SystemAPI.Query, RefRO>().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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/DecoySystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/DecoySystem.cs.meta new file mode 100644 index 000000000..6817ce716 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/DecoySystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 355ca71da85a0f444bb9c312c4dc4643 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 66af99e0e..d072f04c3 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -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(Allocator.Temp); + var decoyPositions = new NativeList(Allocator.Temp); + foreach (var (dx, dh, de) in + SystemAPI.Query, RefRO>() + .WithAll().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(out var coreInteg) && coreInteg.Current > 0; float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton()) : 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().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(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 diff --git a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs index fa6e9126c..12d0bd879 100644 --- a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs @@ -49,6 +49,9 @@ namespace ProjectM.Server /// 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; @@ -62,11 +65,18 @@ namespace ProjectM.Server /// 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); @@ -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 diff --git a/Assets/_Project/Scripts/Server/Combat/ReelSystem.cs b/Assets/_Project/Scripts/Server/Combat/ReelSystem.cs new file mode 100644 index 000000000..6041db750 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ReelSystem.cs @@ -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 +{ + /// + /// SERVER-ONLY Harpooner reel driver. For every Husk carrying an active (stamped by + /// on a Reel-flagged HookPull hit), it re-aims the Husk's + /// toward the CASTER's CURRENT position every tick — so the pull HOMES (unlike + /// the frozen-Dir pull flag). 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 AFTER the predicted group (so THIS tick's + /// stamp is visible) and BEFORE . Releases (clears both states) when the target is + /// within release range, the leash () elapses, or the caster is gone. + /// Bosses never reel (they don't carry KnockbackState/ReelState — knockback-immune, A4). Ticks via + /// ; compared with only. + /// + [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(); + state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().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(Allocator.Temp); + var casterPos = new NativeList(Allocator.Temp); + foreach (var (owner, lt, hp) in + SystemAPI.Query, RefRO, RefRO>().WithAll()) + { + 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, RefRO>() + .WithAll().WithNone()) + { + 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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/ReelSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/ReelSystem.cs.meta new file mode 100644 index 000000000..be981a924 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ReelSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 63b7300c466d5a945a7ce275769efea3 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs new file mode 100644 index 000000000..5a414f966 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs @@ -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 +{ + /// + /// SERVER-ONLY player-cast zone driver (Vortex / LightZone — the LANTERN Aoe/zone Sparks). Borrows the + /// skeleton — the INVERTED invalid-tick guard (a born-0 + /// is lazy-stamped born-correct, never storm-fires) and the = now + period reschedule — but is + /// ENEMY-ONLY (NO friendly fire), stamps = the caster's NetworkId + /// (kills credit the caster — NOT the -1 environment convention), and has NO region gate (a no-world gym). + /// A zone ALSO writes a gentle inward every + /// tick (EnemyAISystem drives it, sole Position writer), so runs BEFORE EnemyAISystem. DestroyEntity when + /// elapses (server-authoritative ghost despawn). Ticks via + /// ; compared with only. + /// + [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 m_KnockbackLookup; + ComponentLookup m_BossLookup; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); + m_BossLookup = state.GetComponentLookup(isReadOnly: true); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().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(Allocator.Temp); + var enemyPositions = new NativeList(Allocator.Temp); + foreach (var (lt, hp, e) in + SystemAPI.Query, RefRO>() + .WithAll().WithNone().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, RefRO>().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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs.meta new file mode 100644 index 000000000..36a05a493 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2f96f56094eb9d4498f9e2da9d819e2a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityDatabaseBlob.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityDatabaseBlob.cs index 0445b13ab..f773bed46 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityDatabaseBlob.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityDatabaseBlob.cs @@ -25,6 +25,8 @@ namespace ProjectM.Simulation public float AutoTargetRange; public float AutoTargetConeRadians; public int CooldownTicks; + public byte EffectFlags; // ProjectileEffectFlag bits stamped at spawn (the Reel bit ⇒ HookPull homes the hit target to the caster). Config, not replicated. + public int DurationTicks; // Aoe/zone + decoy lifetime, ticks (0 = system default). Reel leash is a system const. public FixedString64Bytes Name; } diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs index 79dd99a68..1d540ea13 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs @@ -29,8 +29,8 @@ namespace ProjectM.Simulation /// /// Determinism/idempotency: gated behind IsFirstTimeFullyPredictingTick so a rollback re-sim never /// double-spawns. No wall-clock, no System.Random. Predict-spawn is reserved for the Projectile archetype; - /// Cone applies its effect server-only (cooldown predicted both worlds). Aoe/Hitscan/movement archetypes - /// are handled elsewhere (steps 3/4) and fall through here. Auto-target stays server-only + gamepad-only. + /// Cone/Aoe/Hitscan apply their effect SERVER-ONLY (cooldown predicted on both worlds); the Aoe archetype + /// instantiates its interpolated ghost server-only. Movement (Blink) falls through to BlinkSystem. Auto-target stays server-only + gamepad-only. /// [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(PlayerAimSystem))] @@ -46,9 +46,16 @@ namespace ProjectM.Simulation BufferLookup m_SocketLookup; ComponentLookup m_SocketCdLookup; BufferLookup m_EffSocketLookup; + // Prefab-membership + baked transform of an Aoe spawn prefab, read on the PREFAB entity to pick + // ZoneEffect vs DecoyTag and preserve baked scale. RO; the actual Instantiate is server-only. + ComponentLookup m_ZoneLookup; + ComponentLookup m_DecoyLookup; + ComponentLookup m_LtLookup; /// ~9 degree gap between adjacent Split-Shot projectiles (tunable). const float k_ForkSpreadRad = 0.157f; + const float k_AoeCastAhead = 2.5f; // world units ahead of the caster to drop an Aoe/zone/decoy ghost + const float k_HitscanHalfWidth = 0.6f; // hitscan beam half-width (world units) [BurstCompile] public void OnCreate(ref SystemState state) @@ -61,6 +68,9 @@ namespace ProjectM.Simulation m_SocketLookup = state.GetBufferLookup(isReadOnly: true); m_SocketCdLookup = state.GetComponentLookup(isReadOnly: false); m_EffSocketLookup = state.GetBufferLookup(isReadOnly: true); + m_ZoneLookup = state.GetComponentLookup(isReadOnly: true); + m_DecoyLookup = state.GetComponentLookup(isReadOnly: true); + m_LtLookup = state.GetComponentLookup(isReadOnly: true); } [BurstCompile] @@ -85,6 +95,9 @@ namespace ProjectM.Simulation m_SocketLookup.Update(ref state); m_SocketCdLookup.Update(ref state); m_EffSocketLookup.Update(ref state); + m_ZoneLookup.Update(ref state); + m_DecoyLookup.Update(ref state); + m_LtLookup.Update(ref state); // Server-only LIVING-enemy target set (auto-target assist + Cone cleave), collected once. var candidatePositions = new NativeList(Allocator.Temp); @@ -170,7 +183,74 @@ namespace ProjectM.Simulation cdDirty = true; continue; } - // Aoe/Hitscan (steps 3) and movement/Blink (step 4) are not dispatched here yet. + // AOE / ZONE / DECOY (decoy-wisp, vortex, light-zone): predict the cooldown on BOTH worlds; + // instantiate the interpolated ownerless ghost SERVER-ONLY -- the client must NEVER instantiate it (Build Spec §5). + if (archetype == (byte)AbilityArchetype.Aoe) + { + if (isServer) + { + Entity aoePrefab = Entity.Null; + for (int i = 0; i < abilityPrefabs.Length; i++) + if (abilityPrefabs[i].Id == sparkId) { aoePrefab = abilityPrefabs[i].Prefab; break; } + if (aoePrefab != Entity.Null) + { + float2 aFace = facing.ValueRO.Direction; + aFace = math.lengthsq(aFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(aFace); + float3 spawnPos = xform.ValueRO.Position + new float3(aFace.x, 0f, aFace.y) * k_AoeCastAhead; + spawnPos.y = xform.ValueRO.Position.y; + uint expire = adef.DurationTicks > 0 + ? TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)adef.DurationTicks) : 0u; + var bakedLt = m_LtLookup.HasComponent(aoePrefab) ? m_LtLookup[aoePrefab] : LocalTransform.Identity; + var effect = ecb.Instantiate(aoePrefab); + ecb.SetComponent(effect, bakedLt.WithPosition(spawnPos)); + if (m_ZoneLookup.HasComponent(aoePrefab)) + ecb.SetComponent(effect, new ZoneEffect + { + CasterNetworkId = owner.ValueRO.NetworkId, + Radius = math.max(0.1f, es.Range), + DamagePerPulse = es.Damage, + NextTick = 0u, // lazy-stamped born-correct by ZonePulseSystem (never storm-fires) + ExpireTick = expire, + Flags = m_ZoneLookup[aoePrefab].Flags, // Vortex bit baked on the prefab + }); + else if (m_DecoyLookup.HasComponent(aoePrefab)) + ecb.SetComponent(effect, new DecoyTag { ExpireTick = expire }); + } + } + cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks))); + cdDirty = true; + continue; + } + // HITSCAN: no ghost on either world; predict the cooldown both worlds; server-only swept-beam damage. + // (No Phase-1 Spark uses Hitscan yet; the branch completes the archetype dispatch per Build Spec §5.) + if (archetype == (byte)AbilityArchetype.Hitscan) + { + if (isServer) + { + float2 hFace = facing.ValueRO.Direction; + hFace = math.lengthsq(hFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(hFace); + float hRange = math.max(0.1f, es.Range); + uint hStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); + for (int hi = 0; hi < coneTargets.Length; hi++) + { + float2 rel = coneTargetPos[hi].xz - xform.ValueRO.Position.xz; + float t = math.dot(rel, hFace); + if (t < 0f || t > hRange) continue; + float2 perp = rel - hFace * t; + if (math.lengthsq(perp) > k_HitscanHalfWidth * k_HitscanHalfWidth) continue; + ecb.AppendToBuffer(coneTargets[hi], new DamageEvent + { + Amount = es.Damage, + SourceNetworkId = owner.ValueRO.NetworkId, + SourceTick = hStamp, + }); + } + } + cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks))); + cdDirty = true; + continue; + } + // MOVEMENT (Blink) + any other non-Projectile: fall through (BlinkSystem owns the movement socket's cooldown row). if (archetype != (byte)AbilityArchetype.Projectile) continue; @@ -197,7 +277,7 @@ namespace ProjectM.Simulation byte pierce = bfx.Pierce; byte chain = bfx.Chain; - byte projFlags = pull ? ProjectileEffectFlag.Pull : (byte)0; + byte projFlags = (byte)((pull ? ProjectileEffectFlag.Pull : 0) | adef.EffectFlags); int shots = 1 + math.min((int)bfx.Fork, 8); for (int s = 0; s < shots; s++) diff --git a/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs b/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs index 303ce8da3..ca83a77fe 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/ProjectileEffectState.cs @@ -30,6 +30,8 @@ namespace ProjectM.Simulation /// Bit masks for . public static class ProjectileEffectFlag { + /// bit1 = Reel (HookPull): stamp the homing toward the CASTER instead of a frozen knockback. + public const byte Reel = 2; public const byte Pull = 1; } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/ReelState.cs b/Assets/_Project/Scripts/Simulation/Combat/ReelState.cs new file mode 100644 index 000000000..6db6b359d --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ReelState.cs @@ -0,0 +1,28 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// SERVER-ONLY homing reel on a Husk hit by a Reel-flagged (HookPull) projectile — the Harpooner's signature + /// verb. A sibling of / (NOT a [GhostField]: the + /// reeled position replicates via the stock LocalTransform variant, so it adds no replicated surface). Unlike + /// the frozen-Dir pull flag, this HOMES: ReelSystem re-aims the Husk's + /// toward the caster's CURRENT position every server tick until the target is within release range or + /// elapses — EnemyAISystem stays the SOLE Position writer (it drives the + /// re-aimed knockback; ReelSystem only writes the Dir/Speed/UntilTick). Baked inert on Husk variants + /// (EnemyAuthoring). Stores the caster's (not an Entity) so ReelSystem + /// resolves the live caster position via a per-tick player-by-NetworkId gather. Ticks via + /// ; compared with only. + /// + public struct ReelState : IComponentData + { + /// NetworkId of the reeling caster; ReelSystem homes toward this player's live position. + public int CasterNetworkId; + + /// Reel pull speed (world units/sec) written into KnockbackState.Speed each tick. + public float Speed; + + /// Raw tick the reel leash ends (NonZero). 0 = not reeling; active while .IsNewerThan(serverTick). + public uint ExpireTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ReelState.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ReelState.cs.meta new file mode 100644 index 000000000..0cc6a9379 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ReelState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f6d64d0ff96ddd44a95c296e89442e6f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs new file mode 100644 index 000000000..a3d6d7375 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs @@ -0,0 +1,54 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// SERVER-ONLY periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone + /// archetype. NOT a [GhostField]: the zone is a server-spawned INTERPOLATED ownerless ghost whose + /// position replicates via the stock LocalTransform variant; only its damage schedule lives here, server-side. + /// ZonePulseSystem borrows the GeyserEruptSystem skeleton (the INVERTED invalid-tick guard so a + /// born-0 tick never storm-fires + the = now + period reschedule) but is ENEMY-ONLY (no friendly fire), + /// stamps = (kills credit the caster, + /// feeding KillRewardSystem — NOT the -1 environment convention), and has NO region gate (a no-world gym). + /// DestroyEntity when elapses. + /// + public struct ZoneEffect : IComponentData + { + /// NetworkId of the casting player (damage/kill attribution). + public int CasterNetworkId; + + /// Planar (XZ) damage radius, world units. + public float Radius; + + /// Damage dealt to each living enemy in radius per pulse. + public float DamagePerPulse; + + /// Raw next-pulse tick (NonZero). 0 = unscheduled → lazy-stamped born-correct (GeyserErupt H2 rule, never storm-fires). + public uint NextTick; + + /// Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick). + public uint ExpireTick; + + /// See . bit0 = Vortex (also pull enemies toward the zone center each pulse). + public byte Flags; + } + + /// Bit masks for . + public static class ZoneEffectFlag + { + public const byte Vortex = 1; + } + + /// + /// SERVER-ONLY decoy-wisp state (the Aoe/spawn Spark) — a server-spawned INTERPOLATED ownerless ghost that + /// draws Husk aggro. EnemyAISystem gathers DecoyTag entities into its target set so Husks path to and + /// strike the decoy (soaking hits via its Health/DamageEvent buffer); it despawns on Health <= 0 or + /// when elapses (both handled where EnemyAISystem already iterates decoys). Position + /// replicates via the stock LocalTransform variant — NOT a [GhostField]. + /// + public struct DecoyTag : IComponentData + { + /// Raw tick the decoy despawns (NonZero). 0 = no timeout; active while .IsNewerThan(serverTick). + public uint ExpireTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs.meta new file mode 100644 index 000000000..ab938c583 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 28a356c03de9cfc4c839924c5cb14f5c \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs b/Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs new file mode 100644 index 000000000..7978f2762 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs @@ -0,0 +1,127 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the server-only (LANTERN Vortex/LightZone). + /// Guards the Build Spec RS-5 invariants: a player-cast zone damages ENEMIES ONLY (no friendly fire), credits + /// the CASTER's NetworkId (kills feed KillReward), respects its radius, and despawns on lifetime elapse. The + /// system reads a NetworkTime singleton (constructed here) and plays its ECB back immediately, so one + /// group.Update() resolves within a single tick. [WorldSystemFilter] is ignored under manual registration. + /// + public class ZonePulseSystemTests + { + const uint kTick = 100; // "now" server tick for the fixture + + static (World world, SimulationSystemGroup group) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + + // NetworkTime singleton at a valid, non-zero ServerTick. + var nt = world.EntityManager.CreateEntity(typeof(NetworkTime)); + world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(kTick) }); + return (world, group); + } + + static Entity MakeEnemy(EntityManager em, float3 pos, float hp = 100f) + { + var e = em.CreateEntity(typeof(EnemyTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform)); + em.SetComponentData(e, new Health { Current = hp, Max = hp }); + em.SetComponentData(e, LocalTransform.FromPosition(pos)); + return e; + } + + static Entity MakeZone(EntityManager em, float3 pos, int casterId, float radius, float dmg, uint nextTick, uint expireTick, byte flags = 0) + { + var z = em.CreateEntity(typeof(ZoneEffect), typeof(LocalTransform)); + em.SetComponentData(z, LocalTransform.FromPosition(pos)); + em.SetComponentData(z, new ZoneEffect + { + CasterNetworkId = casterId, + Radius = radius, + DamagePerPulse = dmg, + NextTick = nextTick, + ExpireTick = expireTick, + Flags = flags, + }); + return z; + } + + [Test] + public void Pulse_Damages_Enemies_In_Radius_Credits_Caster_And_Spares_Players() + { + var (world, group) = MakeWorld("ZonePulse_EnemyOnly"); + using (world) + { + var em = world.EntityManager; + var inRange = MakeEnemy(em, new float3(2f, 0f, 0f)); + var outRange = MakeEnemy(em, new float3(20f, 0f, 0f)); + + // A living player standing INSIDE the zone must take NO friendly fire. + var player = em.CreateEntity(typeof(PlayerTag), typeof(Health), typeof(DamageEvent), typeof(LocalTransform)); + em.SetComponentData(player, new Health { Current = 100f, Max = 100f }); + em.SetComponentData(player, LocalTransform.FromPosition(new float3(1f, 0f, 0f))); + + // NextTick == now → DUE this tick (radius 5, 10 dmg/pulse, caster 42, never expires). + MakeZone(em, float3.zero, casterId: 42, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 0u); + + group.Update(); + + var inBuf = em.GetBuffer(inRange); + Assert.AreEqual(1, inBuf.Length, "An enemy inside the radius takes exactly one pulse."); + Assert.AreEqual(10f, inBuf[0].Amount, 1e-4f, "Pulse deals DamagePerPulse."); + Assert.AreEqual(42, inBuf[0].SourceNetworkId, "The pulse credits the caster's NetworkId (kill attribution)."); + + Assert.AreEqual(0, em.GetBuffer(outRange).Length, "An enemy outside the radius is untouched."); + Assert.AreEqual(0, em.GetBuffer(player).Length, "A player in the zone takes NO friendly fire (enemy-only)."); + } + } + + [Test] + public void Born_Zero_NextTick_Lazy_Stamps_And_Does_Not_Storm_Fire() + { + var (world, group) = MakeWorld("ZonePulse_BornZero"); + using (world) + { + var em = world.EntityManager; + var enemy = MakeEnemy(em, new float3(1f, 0f, 0f)); + // NextTick 0 = unscheduled: the INVERTED guard must lazy-stamp born-correct, NOT fire this tick. + var zone = MakeZone(em, float3.zero, casterId: 7, radius: 5f, dmg: 10f, nextTick: 0u, expireTick: 0u); + + group.Update(); + + Assert.AreEqual(0, em.GetBuffer(enemy).Length, + "A born-0 NextTick must lazy-stamp (never storm-fire) on the first tick."); + Assert.AreNotEqual(0u, em.GetComponentData(zone).NextTick, + "NextTick must be stamped born-correct (non-zero) after the first tick."); + } + } + + [Test] + public void Expired_Zone_Is_Despawned() + { + var (world, group) = MakeWorld("ZonePulse_Expire"); + using (world) + { + var em = world.EntityManager; + // ExpireTick in the past (50 < now 100) → despawn this tick. + var zone = MakeZone(em, float3.zero, casterId: 1, radius: 5f, dmg: 10f, nextTick: kTick, expireTick: 50u); + + group.Update(); + + Assert.IsFalse(em.Exists(zone), "A zone past its ExpireTick is destroyed (server-authoritative despawn)."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs.meta new file mode 100644 index 000000000..4ad7b5686 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZonePulseSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 62c15e84019e90549a538044f708f5dd \ No newline at end of file