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"); // 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; // --- LOCAL owner (CC velocity) --- var localJob = new LocalDriveJob { moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead, isAttacking = k_IsAttacking, 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, 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); } // 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; public NetworkTick serverTick; public uint attackTicks; void Execute( AnimatorControllerParameterIndexTableComponent indexTable, DynamicBuffer parametersArr, in PlayerFacing facing, in EffectiveCharacterStats stats, in KinematicCharacterBody body, in MeleeCombo melee, 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)); } } // 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; 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, 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)); } } // 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); } } }