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>().WithAll()) { 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() .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 = serverTick.TickIndexForValidTick + cooldownTicks; } ecb.Playback(state.EntityManager); ecb.Dispose(); candidatePositions.Dispose(); } } }