using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
///
/// 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.
///
/// 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).
///
/// 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.
///
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
state.RequireForUpdate();
}
[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();
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();
var abilityPrefabs = SystemAPI.GetBuffer(dbEntity);
bool isServer = state.WorldUnmanaged.IsServer();
// Server-only auto-target candidate set: training-dummy world XZ positions, collected once.
var candidatePositions = new NativeList(Allocator.Temp);
if (isServer)
{
foreach (var dummyTransform in
SystemAPI.Query>().WithAny())
{
candidatePositions.Add(dummyTransform.ValueRO.Position);
}
}
var candidates = candidatePositions.AsArray();
var ecb = new EntityCommandBuffer(state.WorldUpdateAllocator);
foreach (var (input, facing, xform, eff, abilityRef, cd, owner, entity) in
SystemAPI.Query, RefRO, RefRO,
RefRO, RefRO, RefRW,
RefRO>()
.WithAll().WithDisabled()
.WithEntityAccess())
{
// The InputEvent on the component carries the per-tick delta: set => fired this tick.
if (!input.ValueRO.Fire.IsSet)
continue;
// 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
}
// 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.
var inputBuffer = SystemAPI.GetBuffer>(entity);
if (!inputBuffer.GetDataAtTick(serverTick, out var applied))
continue;
uint absoluteFireCount = applied.InternalInput.Fire.Count;
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.
float2 dir = rawAim;
if (isServer && eff.ValueRO.AutoTargetRange > 0f)
{
dir = AutoTarget.Resolve(
xform.ValueRO.Position,
rawAim,
eff.ValueRO.AutoTargetRange,
eff.ValueRO.AutoTargetConeRadians,
candidates);
}
uint spawnId = (uint)owner.ValueRO.NetworkId << 16 | absoluteFireCount;
var projectile = ecb.Instantiate(prefab);
float3 planarDir = new float3(dir.x, 0f, dir.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 = math.normalize(dir),
SpawnId = spawnId,
Speed = eff.ValueRO.ProjectileSpeed,
Damage = eff.ValueRO.Damage,
Range = eff.ValueRO.Range,
DistanceTravelled = 0f,
});
// 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);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
candidatePositions.Dispose();
}
}
}