Files
Project-M/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs
T
kronic 3836e9c842 Phase 1 B3: enemies die with a corpse window instead of popping out of existence
Server: HealthApplyDamageSystem marks EnemyTag Dying{UntilTick} (TickUtil.
NonZero, ~54 ticks) on the lethal 0-crossing instead of instant destroy,
zeroes every live cue (replicated AttackWindup, LungeState + IsLunging bit,
KnockbackState), destroys on expiry; plain-world tests keep instant destroy
(no NetworkTime) so the suite's assertions stay meaningful.

Every consumer now ignores corpses (all confirmed entity-count/unfiltered by
the design review): EnemyAISystem all 4 passes, BossAISystem brain + summon
cap, RoomEnemyDirector room-clear + MaxAlive fit, WaveSystem cap + cleared,
CyclePhaseSystem breach wipe + DefendCleared, ThreatDirector timeout cull,
TurretFire targeting, CoreDamage drain, ProjectileDamage snapshot (corpses
are not shields), AbilityFire auto-aim candidates, debug cull + telemetry.
Wipe passes skip Dying to avoid cross-ECB double-destroys.

Client: EnemyAnimationDriveSystem finally drives the controller's IsDead
param (Health.Current<=0 is the replicated death read; corpse locomotion
zeroed); CombatFeedbackSystem moves the kill CRUNCH to the 0-crossing (the
prune-edge timing would land it ~1 s late) - the prune keeps a small
dissolve puff for corpses and the legacy full read for alive-vanish culls.

456/456 EditMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:34:12 -07:00

242 lines
13 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
{
// C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<AbilityDatabase>();
state.RequireForUpdate<NetworkTime>();
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
}
[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();
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
// 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())
{
if (th.ValueRO.Current <= 0f) continue; // B3: corpses are neither aim magnets nor cleave targets
candidatePositions.Add(tx.ValueRO.Position);
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,
});
// C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee
// (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4).
if (m_KnockbackLookup.HasComponent(coneTargets[ci]) && !m_BossLookup.HasComponent(coneTargets[ci]))
{
float3 kd3 = coneTargetPos[ci] - xform.ValueRO.Position;
float2 kdir = math.lengthsq(kd3.xz) > 1e-6f ? math.normalize(kd3.xz) : cFace;
m_KnockbackLookup[coneTargets[ci]] = new KnockbackState
{
Dir = kdir,
Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
};
}
}
}
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();
}
}
}