using ProjectM.Simulation; using Rukhanka; // FastAnimatorParameter, AnimatorParametersAspect, ParameterValue, // AnimatorControllerParameterComponent, AnimatorControllerParameterIndexTableComponent, // RukhankaAnimationSystemGroup using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; // GhostOwnerIsLocal, NetworkTime, NetworkTick using Unity.Transforms; // LocalTransform using Unity.CharacterController; // KinematicCharacterBody namespace ProjectM.Client { /// /// Client-only animation driver. OBSERVES authoritative/replicated state and writes Rukhanka animator /// blend params; never mutates the sim (presentation-only). Runs once/frame in the client/local world, /// BEFORE Rukhanka evaluates the controller this frame (same-frame, no 1-tick lag). NOTE: this lands in /// SimulationSystemGroup (via UpdateBefore the Rukhanka group, which itself has no UpdateInGroup), a /// deliberate, documented exception to the project's "all juice = PresentationSystemGroup" rule -- the /// params MUST be set before Rukhanka's same-frame controller eval (see DR-022). /// /// Drives MoveX/MoveZ/Speed/IsDead from locomotion + IsDead, plus IsAttacking (MC-4) pulsed from the /// replicated swing window so the AC_PlayerTopDown MeleeSwing state plays per swing. /// /// Two paths: /// LOCAL (owner-predicted, GhostOwnerIsLocal ENABLED): realized CC RelativeVelocity (wall-aware). /// REMOTE (interpolated, GhostOwnerIsLocal DISABLED): KinematicCharacterBody is NOT a [GhostField] and /// the CC processor is owner-only, so RelativeVelocity stays baked-zero on remotes -> derive /// planar velocity from replicated LocalTransform.Position deltas. PlayerFacing.Direction is a /// [GhostField] (valid on remotes); EffectiveCharacterStats.MoveSpeed is derived locally each /// tick by StatRecomputeSystem (present on remotes). MeleeCombo replicates so teammates' swings /// animate too. Cache prevPos per Entity, prune each frame. /// [WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation | WorldSystemFilterFlags.ClientSimulation)] [UpdateBefore(typeof(RukhankaAnimationSystemGroup))] [RequireMatchingQueriesForUpdate] public partial class PlayerAnimationDriveSystem : SystemBase { // Perfect-hash keys, built once (managed string ctor). Names MUST match AC_PlayerTopDown.controller // parameter names exactly. Immutable readonly hashes -> domain-reload safe. static readonly FastAnimatorParameter k_MoveX = new FastAnimatorParameter("MoveX"); static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ"); static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed"); static readonly FastAnimatorParameter k_IsDead = new FastAnimatorParameter("IsDead"); static readonly FastAnimatorParameter k_IsAttacking = new FastAnimatorParameter("IsAttacking"); static readonly FastAnimatorParameter k_IsFiring = new FastAnimatorParameter("IsFiring"); // Blender-authored A_Fire_OneHand (B7) static readonly FastAnimatorParameter k_IsDashing = new FastAnimatorParameter("IsDashing"); // Blender-authored A_Dash_Lean (local player only - DashState is not replicated) static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter("ComboStep"); // replicated MeleeCombo.Step -> per-step swing clips (Swing1/2/3) static readonly FastAnimatorParameter k_IsCone = new FastAnimatorParameter("IsCone"); // ability archetype (replicated AbilityRef + blob): Cone -> two-hand slam, else the shot // Ticks after a swing-start that IsAttacking stays true (drives the MeleeSwing state). Kept < the swing lock // (MeleeRecoverTicks ~16) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State // transition per hit. ~0.22s @ 60Hz. Presentation-only. const uint k_AttackAnimTicks = 13; // Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn). NativeParallelHashMap _prevPos; protected override void OnCreate() { _prevPos = new NativeParallelHashMap(16, Allocator.Persistent); } protected override void OnDestroy() { if (_prevPos.IsCreated) _prevPos.Dispose(); } protected override void OnUpdate() { float dt = SystemAPI.Time.DeltaTime; // wall-frame delta is correct for presentation if (dt < 1e-5f) dt = 1e-5f; // Current authoritative tick for the swing-window check (default = invalid -> IsAttacking stays false). NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; // Ability blob for the per-class special read (Cone -> slam anim); default(BlobAssetReference) if absent. var abilityBlob = SystemAPI.TryGetSingleton(out var adbSingleton) ? adbSingleton.Value : default; // --- LOCAL owner (CC velocity) --- var localJob = new LocalDriveJob { moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead, isAttacking = k_IsAttacking, isFiring = k_IsFiring, isDashing = k_IsDashing, comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob, serverTick = serverTick, attackTicks = k_AttackAnimTicks, }; Dependency = localJob.ScheduleParallel(Dependency); // --- REMOTE players (position-delta). Single-threaded write to the shared prevPos cache. --- var seen = new NativeParallelHashSet(16, Allocator.TempJob); var remoteJob = new RemoteDriveJob { moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead, isAttacking = k_IsAttacking, isFiring = k_IsFiring, isDashing = k_IsDashing, comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob, serverTick = serverTick, attackTicks = k_AttackAnimTicks, dt = dt, prevPos = _prevPos, seen = seen, }; Dependency = remoteJob.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos // Prune stale entries (despawned remotes) AFTER the job, on the main thread. Dependency.Complete(); PruneCache(seen); seen.Dispose(); } void PruneCache(NativeParallelHashSet seen) { using var keys = _prevPos.GetKeyArray(Allocator.Temp); for (int i = 0; i < keys.Length; i++) if (!seen.Contains(keys[i])) _prevPos.Remove(keys[i]); } // True while now is within [SwingStartTick, SwingStartTick + animTicks) -- a per-swing pulse that re-triggers // on each chained swing. NetworkTick arithmetic (wrap-safe). Presentation-only, Burst-safe. static bool SwingActive(in MeleeCombo mc, NetworkTick serverTick, uint animTicks) { if (mc.SwingStartTick == 0u || !serverTick.IsValid) return false; var start = new NetworkTick(mc.SwingStartTick); var end = new NetworkTick(TickUtil.NonZero(mc.SwingStartTick + animTicks)); return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick); } // B7: the class FIRE ability finally plays a body animation - a stateless window derived from the // replicated AbilityCooldown ([GhostField] NextFireTick) minus the locally-derived CooldownTicks // (EffectiveAbilityStats recomputes identically on every world), so it works for the predicted local // player AND interpolated remotes with no cached edges (review B7: edge-caches false-fire on // relevancy/join/rollback). Reuses the swing clip until a dedicated fire clip exists. static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks) { if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false; uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks); var start = new NetworkTick(startRaw); var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks)); return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick); } // LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent so alive // (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the // enable bit; do NOT take it as an `in` parameter (that matches on presence -> drives remotes too). [BurstCompile] [WithAll(typeof(GhostOwnerIsLocal))] [WithPresent(typeof(Dead))] partial struct LocalDriveJob : IJobEntity { public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone; public BlobAssetReference abilityDb; public NetworkTick serverTick; public uint attackTicks; void Execute( AnimatorControllerParameterIndexTableComponent indexTable, DynamicBuffer parametersArr, in PlayerFacing facing, in EffectiveCharacterStats stats, in KinematicCharacterBody body, in MeleeCombo melee, in AbilityCooldown fireCooldown, in EffectiveAbilityStats abilityStats, in AbilityRef abilityRef, in DashState dashState, EnabledRefRO dead) { var a = new AnimatorParametersAspect(parametersArr, indexTable); float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed); Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead); if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks)); if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, FireActive(fireCooldown.NextFireTick, abilityStats.CooldownTicks, serverTick, attackTicks)); // Dash lean: the LOCAL player's predicted DashState window [StartTick, IFrameUntilTick+tail). bool dashActive = false; if (dashState.StartTick != 0u && dashState.IFrameUntilTick != 0u && serverTick.IsValid) { var dStart = new NetworkTick(dashState.StartTick); var dEnd = new NetworkTick(TickUtil.NonZero(dashState.IFrameUntilTick + 6u)); dashActive = dStart.IsValid && dEnd.IsValid && !dStart.IsNewerThan(serverTick) && dEnd.IsNewerThan(serverTick); } if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, dashActive); if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step); bool cone = false; if (abilityDb.IsCreated) { ref var adb = ref abilityDb.Value; if (adb.TryGetAbility(abilityRef.Id, out var adef)) cone = adef.Archetype == (byte)AbilityArchetype.Cone; } if (a.HasParameter(isCone)) a.SetParameterValue(isCone, cone); } } // REMOTE: GhostOwnerIsLocal DISABLED -> interpolated teammates. Velocity from LocalTransform.Position // delta (KinematicCharacterBody.RelativeVelocity is baked-zero on remotes, non-GhostField, owner-only). [BurstCompile] [WithDisabled(typeof(GhostOwnerIsLocal))] [WithPresent(typeof(Dead))] partial struct RemoteDriveJob : IJobEntity { public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone; public BlobAssetReference abilityDb; public NetworkTick serverTick; public uint attackTicks; public float dt; public NativeParallelHashMap prevPos; public NativeParallelHashSet seen; void Execute( Entity e, AnimatorControllerParameterIndexTableComponent indexTable, DynamicBuffer parametersArr, in LocalTransform xform, in PlayerFacing facing, in EffectiveCharacterStats stats, in MeleeCombo melee, in AbilityCooldown fireCooldown, in EffectiveAbilityStats abilityStats, in AbilityRef abilityRef, EnabledRefRO dead) { seen.Add(e); float3 cur = xform.Position; float3 vel = float3.zero; if (prevPos.TryGetValue(e, out var prev)) vel = (cur - prev) / dt; prevPos[e] = cur; var a = new AnimatorParametersAspect(parametersArr, indexTable); float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed); Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead); if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks)); if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, FireActive(fireCooldown.NextFireTick, abilityStats.CooldownTicks, serverTick, attackTicks)); if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, false); // DashState is not replicated to remotes if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step); bool cone = false; if (abilityDb.IsCreated) { ref var adb = ref abilityDb.Value; if (adb.TryGetAbility(abilityRef.Id, out var adef)) cone = adef.Archetype == (byte)AbilityArchetype.Cone; } if (a.HasParameter(isCone)) a.SetParameterValue(isCone, cone); } } // ParameterValue has implicit float/bool operators -> SetParameterValue(key, float) / (key, bool) compile. static void Write(ref AnimatorParametersAspect a, float3 p, bool isDeadVal, FastAnimatorParameter moveX, FastAnimatorParameter moveZ, FastAnimatorParameter speed, FastAnimatorParameter isDead) { if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x); if (a.HasParameter(moveZ)) a.SetParameterValue(moveZ, p.y); if (a.HasParameter(speed)) a.SetParameterValue(speed, p.z); if (a.HasParameter(isDead)) a.SetParameterValue(isDead, isDeadVal); } } }