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.Transforms; // LocalTransform namespace ProjectM.Client { /// /// Client-only animation driver for Husk ENEMIES. OBSERVES replicated state and writes Rukhanka animator /// blend params; never mutates the sim (presentation-only). The enemy mirror of the REMOTE path of /// : a Husk is an OWNERLESS INTERPOLATED ghost (server-moved by /// EnemyAISystem, position+rotation via stock LocalTransform replication, no KinematicCharacterBody on the /// client) — structurally identical to a remote player — so planar velocity is derived from replicated /// frame-deltas, facing from the replicated /// (the server faces the target each tick), and the run-blend normalizer from the baked-on-both-worlds /// . The attack telegraph rides the already-replicated /// [GhostField] (non-zero for the ~0.3s wind-up) — no new [GhostField], no server /// change (Rukhanka is stripped server-side by ServerStripAnimationSystem), no ghost-hash change. See DR-023. /// /// Runs in SimulationSystemGroup via [UpdateBefore(RukhankaAnimationSystemGroup)] so params are set before /// Rukhanka's same-frame controller eval (no 1-tick lag) — the same documented exception to the /// "all juice = PresentationSystemGroup" rule the player driver uses. Observe-only, never in the predicted loop. /// /// /// NOTE: deliberately NOT [RequireMatchingQueriesForUpdate]. Husks despawn far more often than players, so the /// per-frame prevPos prune must run EVERY frame (even with zero live Husks) to reclaim the cache entry of a /// just-killed Husk — otherwise one NativeParallelHashMap entry would leak per kill. /// /// [WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation | WorldSystemFilterFlags.ClientSimulation)] [UpdateBefore(typeof(RukhankaAnimationSystemGroup))] public partial class EnemyAnimationDriveSystem : SystemBase { // Perfect-hash keys, built once. Names MUST match AC_EnemyTopDown.controller parameter names exactly. 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_IsAttacking = new FastAnimatorParameter("IsAttacking"); // prevPos cache (per Husk Entity). Pruned every frame (a vanished Husk = a server-authoritative death). NativeParallelHashMap _prevPos; protected override void OnCreate() { _prevPos = new NativeParallelHashMap(64, 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; var seen = new NativeParallelHashSet(64, Allocator.TempJob); var job = new EnemyDriveJob { moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isAttacking = k_IsAttacking, dt = dt, prevPos = _prevPos, seen = seen, }; Dependency = job.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos // Prune stale entries (despawned Husks) 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]); } // Husks are ownerless interpolated ghosts: no local/remote split, no Dead enableable. Velocity from // LocalTransform.Position delta; facing from LocalTransform.Rotation (server-faced); IsAttacking from the // replicated AttackWindup telegraph. The Rukhanka param components match only rigged ghosts. [BurstCompile] [WithAll(typeof(EnemyTag))] partial struct EnemyDriveJob : IJobEntity { public FastAnimatorParameter moveX, moveZ, speed, isAttacking; public float dt; public NativeParallelHashMap prevPos; public NativeParallelHashSet seen; void Execute( Entity e, AnimatorControllerParameterIndexTableComponent indexTable, DynamicBuffer parametersArr, in LocalTransform xform, in EnemyStats stats, in AttackWindup windup) { 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; float2 facing = AnimParamMath.PlanarForward(xform.Rotation); float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed); bool attacking = windup.WindUpUntilTick != 0; var a = new AnimatorParametersAspect(parametersArr, indexTable); 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(isAttacking)) a.SetParameterValue(isAttacking, attacking); } } } }