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