0948d49886
byte Archetype on AbilityDefBlob (AbilityArchetype enum: Projectile/Hitscan/Cone/Aoe), authored on AbilityDefinition + baked in AbilityDatabaseAuthoring, read at dispatch in AbilityFireSystem -- NOT folded through EffectiveAbilityStats/StatRecomputeSystem (static identity, not a tunable stat; MC-4 review BURST-1). All current abilities are Projectile (0) -> zero behaviour change; the dispatch read-point is the de-risk spike for MC-6 hitscan/cone/aoe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
9.3 KiB
C#
185 lines
9.3 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);
|
|
var abilityDb = SystemAPI.GetSingleton<AbilityDatabase>();
|
|
|
|
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
|
|
}
|
|
|
|
// 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;
|
|
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.
|
|
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, 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)
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
}
|