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"); static readonly FastAnimatorParameter k_IsDead = new FastAnimatorParameter("IsDead"); // B3: the controller's Death state existed but was never driven static readonly FastAnimatorParameter k_IsHit = new FastAnimatorParameter("IsHit"); // 07-21 hit-react (G5): pulsed off replicated Health-drop edges static readonly FastAnimatorParameter k_IsHitHeavy = new FastAnimatorParameter("IsHitHeavy"); // heavy tier -> HitStagger (aligns with the server's B2 poise break) /// 07-21: per-Husk anim cache -- position (velocity derivation), Health (the hit-react damage /// edge), and the active flinch pulse. Pruned every frame (a vanished Husk = a server-authoritative death). struct EnemyAnimCache { public float3 Pos; public float Hp; public float ReactUntil; // wall-clock ElapsedTime the flinch pulse ends (0 = none) public bool Heavy; // this pulse is the stagger tier } 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, isDead = k_IsDead, isHit = k_IsHit, isHitHeavy = k_IsHitHeavy, // 07-21 hit-react knobs (read main-thread, passed by value -- never FeelConfig inside Burst) el = (float)SystemAPI.Time.ElapsedTime, reactSeconds = math.max(0f, FeelConfig.HitReactSeconds), staggerSeconds = math.max(0f, FeelConfig.HitStaggerSeconds), staggerDamage = math.max(1f, FeelConfig.HitReactStaggerDamage), dt = dt, prevPos = _prevPos, seen = seen, isLunging = SystemAPI.GetComponentLookup(true), }; 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, isDead, isHit, isHitHeavy; public float el, reactSeconds, staggerSeconds, staggerDamage; // 07-21 hit-react (reactSeconds 0 = off) [Unity.Collections.ReadOnly] public ComponentLookup isLunging; // A7: a lunge has no AttackWindup; OR it in so the boss/Charger attack anim plays during the committed lunge 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, in Health health) { seen.Add(e); float3 cur = xform.Position; float3 vel = float3.zero; var cache = new EnemyAnimCache { Pos = cur, Hp = health.Current }; if (prevPos.TryGetValue(e, out var prev)) { vel = (cur - prev.Pos) / dt; cache.ReactUntil = prev.ReactUntil; cache.Heavy = prev.Heavy; // 07-21 hit-react (G5): a replicated Health DROP on a living Husk = the flinch edge. The heavy // tier (>= staggerDamage, e.g. the finisher) plays the stagger -- aligned with the server's B2 // poise break, so the anim never claims an interrupt the sim didn't make. float drop = prev.Hp - health.Current; if (drop > 0.01f && health.Current > 0f && reactSeconds > 0f) { cache.Heavy = drop >= staggerDamage; cache.ReactUntil = el + (cache.Heavy ? staggerSeconds : reactSeconds); } } prevPos[e] = cache; float2 facing = AnimParamMath.PlanarForward(xform.Rotation); float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed); bool attacking = windup.WindUpUntilTick != 0 || (isLunging.HasComponent(e) && isLunging.IsComponentEnabled(e)); // A7: the committed lunge (which zeroes AttackWindup) still animates as an attack // B3: the corpse window — Health.Current is replicated, so <=0 IS the death read. A corpse // plays the Death state and nothing else (no jog, no frozen attack). bool dead = health.Current <= 0f; if (dead) { p = float3.zero; attacking = false; } 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); if (a.HasParameter(isDead)) a.SetParameterValue(isDead, dead); // 07-21 hit-react pulse (a corpse never flinches; the controller adds the windup-honesty gate). bool hitActive = !dead && cache.ReactUntil > 0f && el < cache.ReactUntil; if (a.HasParameter(isHit)) a.SetParameterValue(isHit, hitActive); if (a.HasParameter(isHitHeavy)) a.SetParameterValue(isHitHeavy, hitActive && cache.Heavy); } } } }