Files
Project-M/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs
T
kronic e362aaeb43 Import art/VFX asset packs + game-feel systems; normalize texture extensions to lowercase for LFS
Add BefourStudios SciFi environment packs, Gabriel Aguiar VFX, and the
ShaderCrew Toon Shader embedded packages, plus combat/enemy/wave/death
gameplay systems and supporting vault docs/screenshots.

Rename 11 vendor textures from uppercase .PNG/.HDR to lowercase so the
case-sensitive Git LFS filters (*.png/*.hdr) match on case-sensitive
filesystems (Linux CI, case-sensitive macOS), not just locally where
core.ignorecase=true masks the gap. Each .meta moved with its asset so
GUID references are preserved. All ~1000 binaries tracked via LFS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:50:43 -07:00

174 lines
8.4 KiB
C#

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
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.
///
/// 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.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<AbilityDatabase>();
state.RequireForUpdate<NetworkTime>();
}
[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);
bool isServer = state.WorldUnmanaged.IsServer();
// Server-only auto-target candidate set: training-dummy world XZ positions, collected once.
var candidatePositions = new NativeList<float3>(Allocator.Temp);
if (isServer)
{
foreach (var dummyTransform in
SystemAPI.Query<RefRO<LocalTransform>>().WithAny<TrainingDummyTag, EnemyTag>())
{
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<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>,
RefRO<EffectiveAbilityStats>, RefRO<AbilityRef>, RefRW<AbilityCooldown>,
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)
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<InputBufferData<PlayerInput>>(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();
}
}
}