Initial Combat Implementation
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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>>().WithAll<TrainingDummyTag>())
|
||||
{
|
||||
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>()
|
||||
.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 = serverTick.TickIndexForValidTick + cooldownTicks;
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
candidatePositions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user