a7fdd6f71d
The per-player class travels on GoInGameRequest.ClassId (client reads a ClassSelection singleton); GoInGameServerSystem seeds the class at spawn via ClassTraits (AbilityRef + permanent trait StatModifiers on a reserved ClassSourceId; CharacterStatsRef stays Default so the DRG-asymmetry deltas ride the replicated OwnerSendType.All buffer). AbilityFireSystem gains the aim-directed Cone archetype: cooldown predicted both worlds, server-only cone damage to living enemies (same-tick, SourceTick-stamped, like the melee cleave). 345/345. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
12 KiB
C#
220 lines
12 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 target set (LIVING enemies/dummies), collected once: positions feed the gamepad
|
|
// auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave.
|
|
var candidatePositions = new NativeList<float3>(Allocator.Temp);
|
|
var coneTargets = new NativeList<Entity>(Allocator.Temp);
|
|
var coneTargetPos = new NativeList<float3>(Allocator.Temp);
|
|
if (isServer)
|
|
{
|
|
foreach (var (tx, th, te) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>().WithAny<TrainingDummyTag, EnemyTag>().WithEntityAccess())
|
|
{
|
|
candidatePositions.Add(tx.ValueRO.Position);
|
|
if (th.ValueRO.Current > 0f) { coneTargets.Add(te); coneTargetPos.Add(tx.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;
|
|
|
|
// Slice 2: the Warrior's aim-directed CONE secondary (no projectile ghost). Predict the cooldown on
|
|
// both worlds; apply server-only cone damage to living enemies (mirrors the MeleeComboSystem cleave,
|
|
// same-tick: this runs before HealthApplyDamageSystem in the predicted group). SourceTick-stamped.
|
|
if (archetype == (byte)AbilityArchetype.Cone)
|
|
{
|
|
if (isServer)
|
|
{
|
|
float2 cFace = facing.ValueRO.Direction;
|
|
cFace = math.lengthsq(cFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(cFace);
|
|
float cRange = math.max(0.1f, eff.ValueRO.Range);
|
|
float cCosHalf = math.cos(math.clamp(eff.ValueRO.AutoTargetConeRadians, 0.01f, 3.14159f));
|
|
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
|
for (int ci = 0; ci < coneTargets.Length; ci++)
|
|
{
|
|
if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
|
|
continue;
|
|
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
|
|
{
|
|
Amount = eff.ValueRO.Damage,
|
|
SourceNetworkId = owner.ValueRO.NetworkId,
|
|
SourceTick = cStamp,
|
|
});
|
|
}
|
|
}
|
|
uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
|
cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + coneCd);
|
|
continue;
|
|
}
|
|
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();
|
|
coneTargets.Dispose();
|
|
coneTargetPos.Dispose();
|
|
}
|
|
}
|
|
}
|