|
|
|
@@ -8,49 +8,46 @@ using Unity.Transforms;
|
|
|
|
|
namespace ProjectM.Simulation
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Predicted "fire" ability: on the single fully-predicting pass of each tick, spawns a
|
|
|
|
|
/// Projectile ghost for every player whose PlayerInput.Fire event is set this tick and whose
|
|
|
|
|
/// AbilityCooldown has elapsed. Runs in both worlds: the owning client predict-spawns the
|
|
|
|
|
/// projectile (classified into the authoritative ghost by ProjectileClassificationSystem via the
|
|
|
|
|
/// Projectile.SpawnId key), and the server spawns the replicated truth.
|
|
|
|
|
/// LANTERN 4-socket predicted "fire". On the single fully-predicting pass of each tick, for every player
|
|
|
|
|
/// it loops the 4 ability sockets: a socket whose Spark is set (AbilitySocket.SparkId != 0), whose own
|
|
|
|
|
/// fire event (PlayerInput.Socket0..3) is set this tick, and whose per-socket SocketCooldown has elapsed
|
|
|
|
|
/// fires that Spark. Runs in both worlds: the owning client predict-spawns a Projectile ghost (classified
|
|
|
|
|
/// into the authoritative ghost by ProjectileClassificationSystem via Projectile.SpawnId), and the server
|
|
|
|
|
/// spawns the replicated truth.
|
|
|
|
|
///
|
|
|
|
|
/// M3 data-driven: ability stats are read from the per-entity EffectiveAbilityStats (authored base
|
|
|
|
|
/// from the AbilityDatabase blob keyed by AbilityRef, folded with the replicated StatModifier buffer
|
|
|
|
|
/// by StatRecomputeSystem earlier this tick). The projectile ghost prefab is resolved per ability via
|
|
|
|
|
/// the AbilityPrefabElement buffer on the AbilityDatabase singleton. Effective Speed/Damage/Range are
|
|
|
|
|
/// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
|
|
|
|
|
/// predicted + server projectiles match (both folded the same replicated modifiers).
|
|
|
|
|
/// The single AbilityRef/AbilityCooldown/EffectiveAbilityStats model was replaced by the socket kit:
|
|
|
|
|
/// AbilitySocket (loadout), SocketCooldown (hot per-socket cooldown), EffectiveSocketStats (per-socket
|
|
|
|
|
/// folded stats from StatRecomputeSystem). To stay under the 7-type SystemAPI.Query cap, the query holds
|
|
|
|
|
/// only PlayerInput/PlayerFacing/LocalTransform/GhostOwner and reads the socket data by entity via
|
|
|
|
|
/// BufferLookup/ComponentLookup (mirroring the BoonEffects lookup).
|
|
|
|
|
///
|
|
|
|
|
/// Phase 1.7 mechanic-changer boons ride the owner-replicated <see cref="BoonEffects"/> (SendToOwner, so the
|
|
|
|
|
/// predicting owner has it — the .WithAll<Simulate>() filter means only the owner's own player is processed
|
|
|
|
|
/// client-side), read via a ComponentLookup keyed by the player (the query is already at the 7-type SystemAPI
|
|
|
|
|
/// limit). FORK fans <c>Fork</c> extra predicted projectiles in a symmetric spread, each with a UNIQUE
|
|
|
|
|
/// deterministic SpawnId (fork index packed into the low bits) so classification predicts each. PIERCE/CHAIN/PULL
|
|
|
|
|
/// are seeded into the server-only <see cref="ProjectileEffectState"/> at spawn (resolved by ProjectileDamageSystem).
|
|
|
|
|
/// SpawnId key (owner14 | socket2 | fireCount12 | fork4) reserves socket bits so two sockets firing the
|
|
|
|
|
/// same-prefab projectile on one tick classify to DISTINCT ghosts (the review's NP-1/RS-1/DB-3 fix). The
|
|
|
|
|
/// per-socket fire count comes from the replicated command buffer at ServerTick (not a local counter) so
|
|
|
|
|
/// client and server agree. FORK fans extra predicted projectiles, each with a unique fork index in the
|
|
|
|
|
/// low bits; PIERCE/CHAIN/PULL seed the server-only ProjectileEffectState.
|
|
|
|
|
///
|
|
|
|
|
/// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all
|
|
|
|
|
/// non-idempotent effects (spawning, cooldown advance) are gated behind
|
|
|
|
|
/// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute
|
|
|
|
|
/// fire count comes from the replicated input command buffer at NetworkTime.ServerTick (not a
|
|
|
|
|
/// local counter) so the SpawnId matches on client and server. No wall-clock, no System.Random,
|
|
|
|
|
/// no UnityEngine.Time.
|
|
|
|
|
///
|
|
|
|
|
/// Auto-target is intentionally server-only: the client fires along raw aim, and the server's
|
|
|
|
|
/// authoritative Projectile.Direction GhostField reconciles the predicted projectile to the
|
|
|
|
|
/// assisted heading.
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
|
|
|
|
[UpdateAfter(typeof(PlayerAimSystem))]
|
|
|
|
|
[BurstCompile]
|
|
|
|
|
public partial struct AbilityFireSystem : ISystem
|
|
|
|
|
{
|
|
|
|
|
// C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
|
|
|
|
|
// Server-only knockback stamp for the Cone (guarded + boss-immune).
|
|
|
|
|
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
|
|
|
|
ComponentLookup<BossState> m_BossLookup;
|
|
|
|
|
// Phase 1.7: owner-replicated mechanic-changer boons, read by the player entity (query is at the 7-type cap).
|
|
|
|
|
// Owner-replicated mechanic-changer boons, read by the player entity.
|
|
|
|
|
ComponentLookup<BoonEffects> m_BoonEffectsLookup;
|
|
|
|
|
// LANTERN socket kit, read by the player entity so the fire query stays at 4 type args (7-arg cap).
|
|
|
|
|
BufferLookup<AbilitySocket> m_SocketLookup;
|
|
|
|
|
ComponentLookup<SocketCooldown> m_SocketCdLookup;
|
|
|
|
|
BufferLookup<EffectiveSocketStats> m_EffSocketLookup;
|
|
|
|
|
|
|
|
|
|
/// <summary>~9° gap between adjacent Split-Shot projectiles (tunable).</summary>
|
|
|
|
|
/// <summary>~9 degree gap between adjacent Split-Shot projectiles (tunable).</summary>
|
|
|
|
|
const float k_ForkSpreadRad = 0.157f;
|
|
|
|
|
|
|
|
|
|
[BurstCompile]
|
|
|
|
@@ -61,33 +58,35 @@ namespace ProjectM.Simulation
|
|
|
|
|
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
|
|
|
|
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
|
|
|
|
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
|
|
|
|
m_SocketLookup = state.GetBufferLookup<AbilitySocket>(isReadOnly: true);
|
|
|
|
|
m_SocketCdLookup = state.GetComponentLookup<SocketCooldown>(isReadOnly: false);
|
|
|
|
|
m_EffSocketLookup = state.GetBufferLookup<EffectiveSocketStats>(isReadOnly: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[BurstCompile]
|
|
|
|
|
public void OnUpdate(ref SystemState state)
|
|
|
|
|
{
|
|
|
|
|
// Spawning is a one-off effect: only run on the unique fully-predicting pass of this tick
|
|
|
|
|
// so a rollback re-simulation does not double-spawn.
|
|
|
|
|
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
|
|
|
|
|
if (!networkTime.IsFirstTimeFullyPredictingTick)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
var serverTick = networkTime.ServerTick;
|
|
|
|
|
if (!serverTick.IsValid)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
// Per-ability projectile ghost prefabs live on the AbilityDatabase singleton's companion buffer.
|
|
|
|
|
var dbEntity = SystemAPI.GetSingletonEntity<AbilityDatabase>();
|
|
|
|
|
var abilityPrefabs = SystemAPI.GetBuffer<AbilityPrefabElement>(dbEntity);
|
|
|
|
|
var abilityDb = SystemAPI.GetSingleton<AbilityDatabase>();
|
|
|
|
|
ref var adb = ref abilityDb.Value.Value;
|
|
|
|
|
|
|
|
|
|
bool isServer = state.WorldUnmanaged.IsServer();
|
|
|
|
|
m_KnockbackLookup.Update(ref state);
|
|
|
|
|
m_BossLookup.Update(ref state);
|
|
|
|
|
m_BoonEffectsLookup.Update(ref state);
|
|
|
|
|
m_SocketLookup.Update(ref state);
|
|
|
|
|
m_SocketCdLookup.Update(ref state);
|
|
|
|
|
m_EffSocketLookup.Update(ref state);
|
|
|
|
|
|
|
|
|
|
// Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad
|
|
|
|
|
// auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave.
|
|
|
|
|
// Server-only LIVING-enemy target set (auto-target assist + Cone cleave), collected once.
|
|
|
|
|
var candidatePositions = new NativeList<float3>(Allocator.Temp);
|
|
|
|
|
var coneTargets = new NativeList<Entity>(Allocator.Temp);
|
|
|
|
|
var coneTargetPos = new NativeList<float3>(Allocator.Temp);
|
|
|
|
@@ -96,7 +95,7 @@ namespace ProjectM.Simulation
|
|
|
|
|
foreach (var (tx, th, te) in
|
|
|
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>().WithAll<EnemyTag>().WithEntityAccess())
|
|
|
|
|
{
|
|
|
|
|
if (th.ValueRO.Current <= 0f) continue; // B3: corpses are neither aim magnets nor cleave targets
|
|
|
|
|
if (th.ValueRO.Current <= 0f) continue; // corpses are neither aim magnets nor cleave targets
|
|
|
|
|
candidatePositions.Add(tx.ValueRO.Position);
|
|
|
|
|
coneTargets.Add(te); coneTargetPos.Add(tx.ValueRO.Position);
|
|
|
|
|
}
|
|
|
|
@@ -105,163 +104,141 @@ namespace ProjectM.Simulation
|
|
|
|
|
|
|
|
|
|
var ecb = new EntityCommandBuffer(state.WorldUpdateAllocator);
|
|
|
|
|
|
|
|
|
|
foreach (var (input, facing, xform, eff, abilityRef, cd, owner, entity) in
|
|
|
|
|
SystemAPI.Query<RefRO<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>,
|
|
|
|
|
RefRO<EffectiveAbilityStats>, RefRO<AbilityRef>, RefRW<AbilityCooldown>,
|
|
|
|
|
RefRO<GhostOwner>>()
|
|
|
|
|
foreach (var (input, facing, xform, owner, entity) in
|
|
|
|
|
SystemAPI.Query<RefRO<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>>()
|
|
|
|
|
.WithAll<Simulate>().WithDisabled<Dead>()
|
|
|
|
|
.WithEntityAccess())
|
|
|
|
|
{
|
|
|
|
|
// The InputEvent on the component carries the per-tick delta: set => fired this tick.
|
|
|
|
|
if (!input.ValueRO.Fire.IsSet)
|
|
|
|
|
if (!m_SocketLookup.HasBuffer(entity) || !m_SocketCdLookup.HasComponent(entity) || !m_EffSocketLookup.HasBuffer(entity))
|
|
|
|
|
continue;
|
|
|
|
|
var sockets = m_SocketLookup[entity];
|
|
|
|
|
var effSockets = m_EffSocketLookup[entity];
|
|
|
|
|
var cd = m_SocketCdLookup[entity]; // struct copy; written back after mutation
|
|
|
|
|
|
|
|
|
|
// Cooldown gate. NextFireTick == 0 means "ready". Otherwise the player may fire only
|
|
|
|
|
// once serverTick is at-or-newer than the stored tick (i.e. the stored tick is not
|
|
|
|
|
// strictly newer than now).
|
|
|
|
|
uint nextFireRaw = cd.ValueRO.NextFireTick;
|
|
|
|
|
if (nextFireRaw != 0)
|
|
|
|
|
{
|
|
|
|
|
var nextTick = new NetworkTick(nextFireRaw);
|
|
|
|
|
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
|
|
|
|
|
continue; // still cooling down
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 1.7 mechanic-changer boons (owner-replicated; read by entity — see class doc for the 7-type cap).
|
|
|
|
|
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
|
|
|
|
|
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
|
|
|
|
|
|
|
|
|
|
// MC-4 spike for MC-6: dispatch on the authored ability ARCHETYPE byte (baked in the blob, read here -- NOT
|
|
|
|
|
// folded through EffectiveAbilityStats; it is static identity, not a tunable stat). All current
|
|
|
|
|
// abilities are Projectile (0); hitscan/cone/aoe archetypes plug in at this point in MC-6.
|
|
|
|
|
ref var adb = ref abilityDb.Value.Value;
|
|
|
|
|
byte archetype = adb.TryGetAbility(abilityRef.ValueRO.Id, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
|
|
|
|
|
|
|
|
|
|
// Slice 2: the Warrior's aim-directed CONE secondary (no projectile ghost). Predict the cooldown on
|
|
|
|
|
// both worlds; apply server-only cone damage to living enemies (mirrors the MeleeComboSystem cleave,
|
|
|
|
|
// same-tick: this runs before HealthApplyDamageSystem in the predicted group). SourceTick-stamped.
|
|
|
|
|
if (archetype == (byte)AbilityArchetype.Cone)
|
|
|
|
|
{
|
|
|
|
|
if (isServer)
|
|
|
|
|
{
|
|
|
|
|
float2 cFace = facing.ValueRO.Direction;
|
|
|
|
|
cFace = math.lengthsq(cFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(cFace);
|
|
|
|
|
float cRange = math.max(0.1f, eff.ValueRO.Range);
|
|
|
|
|
float cCosHalf = math.cos(math.clamp(eff.ValueRO.AutoTargetConeRadians, 0.01f, 3.14159f));
|
|
|
|
|
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
|
|
|
|
for (int ci = 0; ci < coneTargets.Length; ci++)
|
|
|
|
|
{
|
|
|
|
|
if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
|
|
|
|
|
continue;
|
|
|
|
|
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
|
|
|
|
|
{
|
|
|
|
|
Amount = eff.ValueRO.Damage,
|
|
|
|
|
SourceNetworkId = owner.ValueRO.NetworkId,
|
|
|
|
|
SourceTick = cStamp,
|
|
|
|
|
});
|
|
|
|
|
// C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee
|
|
|
|
|
// (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4).
|
|
|
|
|
// Phase 1.7 Gravity Pull: drag toward the player instead of away.
|
|
|
|
|
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
|
|
|
|
|
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed,
|
|
|
|
|
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
|
|
|
|
cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + coneCd);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (archetype != (byte)AbilityArchetype.Projectile)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
// Resolve the projectile ghost prefab for this player's selected ability id.
|
|
|
|
|
Entity prefab = Entity.Null;
|
|
|
|
|
for (int i = 0; i < abilityPrefabs.Length; i++)
|
|
|
|
|
{
|
|
|
|
|
if (abilityPrefabs[i].Id == abilityRef.ValueRO.Id)
|
|
|
|
|
{
|
|
|
|
|
prefab = abilityPrefabs[i].Prefab;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (prefab == Entity.Null)
|
|
|
|
|
continue; // ability has no projectile prefab wired
|
|
|
|
|
|
|
|
|
|
// Absolute (monotonic) fire count from the replicated command buffer at this tick.
|
|
|
|
|
// This is the classification key shared by client prediction and server truth.
|
|
|
|
|
// Replicated command buffer at this tick (per-socket fire count for the SpawnId + scheme for aim assist).
|
|
|
|
|
var inputBuffer = SystemAPI.GetBuffer<InputBufferData<PlayerInput>>(entity);
|
|
|
|
|
if (!inputBuffer.GetDataAtTick(serverTick, out var applied))
|
|
|
|
|
continue;
|
|
|
|
|
uint absoluteFireCount = applied.InternalInput.Fire.Count;
|
|
|
|
|
bool haveApplied = inputBuffer.GetDataAtTick(serverTick, out var applied);
|
|
|
|
|
|
|
|
|
|
float2 rawAim = facing.ValueRO.Direction;
|
|
|
|
|
if (math.lengthsq(rawAim) < 1e-6f)
|
|
|
|
|
rawAim = new float2(0f, 1f);
|
|
|
|
|
else
|
|
|
|
|
rawAim = math.normalize(rawAim);
|
|
|
|
|
|
|
|
|
|
// Client fires along raw aim. Only the server applies the auto-target assist cone, and only for
|
|
|
|
|
// GAMEPAD shots (precise mouse aim is left exact per the active input scheme).
|
|
|
|
|
float2 dir = rawAim;
|
|
|
|
|
if (isServer && eff.ValueRO.AutoTargetRange > 0f
|
|
|
|
|
&& applied.InternalInput.Scheme == InputSchemeId.Gamepad)
|
|
|
|
|
bool cdDirty = false;
|
|
|
|
|
int socketCount = math.min(SocketId.Count, sockets.Length);
|
|
|
|
|
for (int sk = 0; sk < socketCount; sk++)
|
|
|
|
|
{
|
|
|
|
|
dir = AutoTarget.Resolve(
|
|
|
|
|
xform.ValueRO.Position,
|
|
|
|
|
rawAim,
|
|
|
|
|
eff.ValueRO.AutoTargetRange,
|
|
|
|
|
eff.ValueRO.AutoTargetConeRadians,
|
|
|
|
|
candidates);
|
|
|
|
|
byte sparkId = sockets[sk].SparkId;
|
|
|
|
|
if (sparkId == 0) continue; // empty socket
|
|
|
|
|
if (!input.ValueRO.GetSocket(sk).IsSet) continue; // this socket not fired this tick
|
|
|
|
|
|
|
|
|
|
// Per-socket cooldown gate (0 = ready).
|
|
|
|
|
uint nextFireRaw = cd.Get(sk);
|
|
|
|
|
if (nextFireRaw != 0)
|
|
|
|
|
{
|
|
|
|
|
var nextTick = new NetworkTick(nextFireRaw);
|
|
|
|
|
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
EffectiveSocketStats es = sk < effSockets.Length ? effSockets[sk] : default;
|
|
|
|
|
byte archetype = adb.TryGetAbility(sparkId, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
|
|
|
|
|
|
|
|
|
|
// CONE: no projectile ghost. Predict the cooldown on both worlds; apply server-only cleave.
|
|
|
|
|
if (archetype == (byte)AbilityArchetype.Cone)
|
|
|
|
|
{
|
|
|
|
|
if (isServer)
|
|
|
|
|
{
|
|
|
|
|
float2 cFace = facing.ValueRO.Direction;
|
|
|
|
|
cFace = math.lengthsq(cFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(cFace);
|
|
|
|
|
float cRange = math.max(0.1f, es.Range);
|
|
|
|
|
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
|
|
|
|
|
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
|
|
|
|
for (int ci = 0; ci < coneTargets.Length; ci++)
|
|
|
|
|
{
|
|
|
|
|
if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
|
|
|
|
|
continue;
|
|
|
|
|
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
|
|
|
|
|
{
|
|
|
|
|
Amount = es.Damage,
|
|
|
|
|
SourceNetworkId = owner.ValueRO.NetworkId,
|
|
|
|
|
SourceTick = cStamp,
|
|
|
|
|
});
|
|
|
|
|
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
|
|
|
|
|
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed,
|
|
|
|
|
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
|
|
|
|
|
cdDirty = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// Aoe/Hitscan (steps 3) and movement/Blink (step 4) are not dispatched here yet.
|
|
|
|
|
if (archetype != (byte)AbilityArchetype.Projectile)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
// Resolve the projectile ghost prefab for this Spark id.
|
|
|
|
|
Entity prefab = Entity.Null;
|
|
|
|
|
for (int i = 0; i < abilityPrefabs.Length; i++)
|
|
|
|
|
{
|
|
|
|
|
if (abilityPrefabs[i].Id == sparkId) { prefab = abilityPrefabs[i].Prefab; break; }
|
|
|
|
|
}
|
|
|
|
|
if (prefab == Entity.Null) continue;
|
|
|
|
|
if (!haveApplied) continue;
|
|
|
|
|
|
|
|
|
|
uint socketFireCount = applied.InternalInput.GetSocket(sk).Count;
|
|
|
|
|
|
|
|
|
|
float2 rawAim = facing.ValueRO.Direction;
|
|
|
|
|
rawAim = math.lengthsq(rawAim) < 1e-6f ? new float2(0f, 1f) : math.normalize(rawAim);
|
|
|
|
|
|
|
|
|
|
// Client fires along raw aim; only the server applies the gamepad auto-target assist.
|
|
|
|
|
float2 dir = rawAim;
|
|
|
|
|
if (isServer && es.AutoTargetRange > 0f && applied.InternalInput.Scheme == InputSchemeId.Gamepad)
|
|
|
|
|
{
|
|
|
|
|
dir = AutoTarget.Resolve(xform.ValueRO.Position, rawAim, es.AutoTargetRange, es.AutoTargetConeRadians, candidates);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
byte pierce = bfx.Pierce;
|
|
|
|
|
byte chain = bfx.Chain;
|
|
|
|
|
byte projFlags = pull ? ProjectileEffectFlag.Pull : (byte)0;
|
|
|
|
|
int shots = 1 + math.min((int)bfx.Fork, 8);
|
|
|
|
|
|
|
|
|
|
for (int s = 0; s < shots; s++)
|
|
|
|
|
{
|
|
|
|
|
float offset = (s - (shots - 1) * 0.5f) * k_ForkSpreadRad;
|
|
|
|
|
math.sincos(offset, out float sa, out float ca);
|
|
|
|
|
float2 sdir = math.normalize(new float2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca));
|
|
|
|
|
|
|
|
|
|
// SpawnId: owner(14) | socket(2) | fireCount(12) | fork(4) -- see ProjectileSpawnId.
|
|
|
|
|
uint spawnId = ProjectileSpawnId.Pack(owner.ValueRO.NetworkId, sk, socketFireCount, s);
|
|
|
|
|
|
|
|
|
|
var projectile = ecb.Instantiate(prefab);
|
|
|
|
|
float3 planarDir = new float3(sdir.x, 0f, sdir.y);
|
|
|
|
|
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
|
|
|
|
|
spawnPos.y = xform.ValueRO.Position.y;
|
|
|
|
|
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
|
|
|
|
|
|
|
|
|
|
ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot));
|
|
|
|
|
ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId });
|
|
|
|
|
ecb.SetComponent(projectile, new Projectile
|
|
|
|
|
{
|
|
|
|
|
Direction = sdir,
|
|
|
|
|
SpawnId = spawnId,
|
|
|
|
|
Speed = es.ProjectileSpeed,
|
|
|
|
|
Damage = es.Damage,
|
|
|
|
|
Range = es.Range,
|
|
|
|
|
DistanceTravelled = 0f,
|
|
|
|
|
});
|
|
|
|
|
ecb.SetComponent(projectile, new ProjectileEffectState
|
|
|
|
|
{
|
|
|
|
|
PierceRemaining = pierce,
|
|
|
|
|
ChainRemaining = chain,
|
|
|
|
|
Flags = projFlags,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
|
|
|
|
|
cdDirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 1.7 mechanic-changer seeds. Fork fans (1 + Fork) shots in a symmetric spread; each carries the
|
|
|
|
|
// pierce/chain/pull state into its server-only ProjectileEffectState.
|
|
|
|
|
byte pierce = bfx.Pierce;
|
|
|
|
|
byte chain = bfx.Chain;
|
|
|
|
|
byte projFlags = pull ? ProjectileEffectFlag.Pull : (byte)0;
|
|
|
|
|
int shots = 1 + math.min((int)bfx.Fork, 8); // cap forks: forkIndex is 4 spawnId bits (no wrap) + a sane spread ceiling
|
|
|
|
|
|
|
|
|
|
for (int s = 0; s < shots; s++)
|
|
|
|
|
{
|
|
|
|
|
// Symmetric fan around the (assisted) aim heading; s=0 is centred when there is no fork.
|
|
|
|
|
float offset = (s - (shots - 1) * 0.5f) * k_ForkSpreadRad;
|
|
|
|
|
math.sincos(offset, out float sa, out float ca);
|
|
|
|
|
float2 sdir = math.normalize(new float2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca));
|
|
|
|
|
|
|
|
|
|
// Unique deterministic classification key: owner(16) | fireCount(12) | forkIndex(4).
|
|
|
|
|
uint spawnId = (((uint)owner.ValueRO.NetworkId) << 16) | ((absoluteFireCount & 0x0FFFu) << 4) | (uint)(s & 0xF);
|
|
|
|
|
|
|
|
|
|
var projectile = ecb.Instantiate(prefab);
|
|
|
|
|
float3 planarDir = new float3(sdir.x, 0f, sdir.y);
|
|
|
|
|
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
|
|
|
|
|
spawnPos.y = xform.ValueRO.Position.y;
|
|
|
|
|
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
|
|
|
|
|
|
|
|
|
|
ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot));
|
|
|
|
|
ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId });
|
|
|
|
|
// Snapshot the effective ability stats into the projectile (base + modifiers, computed
|
|
|
|
|
// identically on both worlds), so the move/damage systems need no modifier lookup.
|
|
|
|
|
ecb.SetComponent(projectile, new Projectile
|
|
|
|
|
{
|
|
|
|
|
Direction = sdir,
|
|
|
|
|
SpawnId = spawnId,
|
|
|
|
|
Speed = eff.ValueRO.ProjectileSpeed,
|
|
|
|
|
Damage = eff.ValueRO.Damage,
|
|
|
|
|
Range = eff.ValueRO.Range,
|
|
|
|
|
DistanceTravelled = 0f,
|
|
|
|
|
});
|
|
|
|
|
// Server-only pierce/chain/pull seed (baked inert on the prefab; harmless on the client copy).
|
|
|
|
|
ecb.SetComponent(projectile, new ProjectileEffectState
|
|
|
|
|
{
|
|
|
|
|
PierceRemaining = pierce,
|
|
|
|
|
ChainRemaining = chain,
|
|
|
|
|
Flags = projFlags,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
|
|
|
|
|
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
|
|
|
|
cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + cooldownTicks);
|
|
|
|
|
if (cdDirty) m_SocketCdLookup[entity] = cd;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ecb.Playback(state.EntityManager);
|
|
|
|
|