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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PlayerAimSystem))]
|
||||
@@ -46,9 +46,16 @@ namespace ProjectM.Simulation
|
||||
BufferLookup<AbilitySocket> m_SocketLookup;
|
||||
ComponentLookup<SocketCooldown> m_SocketCdLookup;
|
||||
BufferLookup<EffectiveSocketStats> 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<ZoneEffect> m_ZoneLookup;
|
||||
ComponentLookup<DecoyTag> m_DecoyLookup;
|
||||
ComponentLookup<LocalTransform> m_LtLookup;
|
||||
|
||||
/// <summary>~9 degree gap between adjacent Split-Shot projectiles (tunable).</summary>
|
||||
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<AbilitySocket>(isReadOnly: true);
|
||||
m_SocketCdLookup = state.GetComponentLookup<SocketCooldown>(isReadOnly: false);
|
||||
m_EffSocketLookup = state.GetBufferLookup<EffectiveSocketStats>(isReadOnly: true);
|
||||
m_ZoneLookup = state.GetComponentLookup<ZoneEffect>(isReadOnly: true);
|
||||
m_DecoyLookup = state.GetComponentLookup<DecoyTag>(isReadOnly: true);
|
||||
m_LtLookup = state.GetComponentLookup<LocalTransform>(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<float3>(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++)
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Bit masks for <see cref="ProjectileEffectState.Flags"/>.</summary>
|
||||
public static class ProjectileEffectFlag
|
||||
{
|
||||
/// <summary>bit1 = Reel (HookPull): stamp the homing <see cref="ReelState"/> toward the CASTER instead of a frozen knockback.</summary>
|
||||
public const byte Reel = 2;
|
||||
public const byte Pull = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY homing reel on a Husk hit by a Reel-flagged (HookPull) projectile — the Harpooner's signature
|
||||
/// verb. A sibling of <see cref="KnockbackState"/>/<see cref="LungeState"/> (NOT a <c>[GhostField]</c>: the
|
||||
/// reeled position replicates via the stock LocalTransform variant, so it adds no replicated surface). Unlike
|
||||
/// the frozen-Dir pull flag, this HOMES: <c>ReelSystem</c> re-aims the Husk's <see cref="KnockbackState"/>
|
||||
/// toward the caster's CURRENT position every server tick until the target is within release range or
|
||||
/// <see cref="ExpireTick"/> elapses — <c>EnemyAISystem</c> 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 <see cref="Unity.NetCode.NetworkId"/> (not an Entity) so ReelSystem
|
||||
/// resolves the live caster position via a per-tick player-by-NetworkId gather. Ticks via
|
||||
/// <see cref="TickUtil.NonZero"/>; compared with <see cref="Unity.NetCode.NetworkTick"/> only.
|
||||
/// </summary>
|
||||
public struct ReelState : IComponentData
|
||||
{
|
||||
/// <summary>NetworkId of the reeling caster; ReelSystem homes toward this player's live position.</summary>
|
||||
public int CasterNetworkId;
|
||||
|
||||
/// <summary>Reel pull speed (world units/sec) written into KnockbackState.Speed each tick.</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Raw tick the reel leash ends (NonZero). <c>0</c> = not reeling; active while .IsNewerThan(serverTick).</summary>
|
||||
public uint ExpireTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6d64d0ff96ddd44a95c296e89442e6f
|
||||
@@ -0,0 +1,54 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone
|
||||
/// archetype. NOT a <c>[GhostField]</c>: 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.
|
||||
/// <c>ZonePulseSystem</c> borrows the <c>GeyserEruptSystem</c> skeleton (the INVERTED invalid-tick guard so a
|
||||
/// born-0 tick never storm-fires + the <c>= now + period</c> reschedule) but is ENEMY-ONLY (no friendly fire),
|
||||
/// stamps <see cref="DamageEvent.SourceNetworkId"/> = <see cref="CasterNetworkId"/> (kills credit the caster,
|
||||
/// feeding KillRewardSystem — NOT the -1 environment convention), and has NO region gate (a no-world gym).
|
||||
/// DestroyEntity when <see cref="ExpireTick"/> elapses.
|
||||
/// </summary>
|
||||
public struct ZoneEffect : IComponentData
|
||||
{
|
||||
/// <summary>NetworkId of the casting player (damage/kill attribution).</summary>
|
||||
public int CasterNetworkId;
|
||||
|
||||
/// <summary>Planar (XZ) damage radius, world units.</summary>
|
||||
public float Radius;
|
||||
|
||||
/// <summary>Damage dealt to each living enemy in radius per pulse.</summary>
|
||||
public float DamagePerPulse;
|
||||
|
||||
/// <summary>Raw next-pulse tick (NonZero). <c>0</c> = unscheduled → lazy-stamped born-correct (GeyserErupt H2 rule, never storm-fires).</summary>
|
||||
public uint NextTick;
|
||||
|
||||
/// <summary>Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick).</summary>
|
||||
public uint ExpireTick;
|
||||
|
||||
/// <summary>See <see cref="ZoneEffectFlag"/>. bit0 = Vortex (also pull enemies toward the zone center each pulse).</summary>
|
||||
public byte Flags;
|
||||
}
|
||||
|
||||
/// <summary>Bit masks for <see cref="ZoneEffect.Flags"/>.</summary>
|
||||
public static class ZoneEffectFlag
|
||||
{
|
||||
public const byte Vortex = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-ONLY decoy-wisp state (the Aoe/spawn Spark) — a server-spawned INTERPOLATED ownerless ghost that
|
||||
/// draws Husk aggro. <c>EnemyAISystem</c> 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 <c>Health <= 0</c> or
|
||||
/// when <see cref="ExpireTick"/> elapses (both handled where EnemyAISystem already iterates decoys). Position
|
||||
/// replicates via the stock LocalTransform variant — NOT a <c>[GhostField]</c>.
|
||||
/// </summary>
|
||||
public struct DecoyTag : IComponentData
|
||||
{
|
||||
/// <summary>Raw tick the decoy despawns (NonZero). <c>0</c> = no timeout; active while .IsNewerThan(serverTick).</summary>
|
||||
public uint ExpireTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28a356c03de9cfc4c839924c5cb14f5c
|
||||
Reference in New Issue
Block a user