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_Bank = new FastAnimatorParameter("Bank"); static readonly FastAnimatorParameter k_StrideScale = new FastAnimatorParameter("StrideScale"); // 07-16b: Locomotion playback = planarSpeed / TrudgeNaturalSpeed (foot-skate fix) // 07-16: additive Posture layer — signed facing turn rate, local player only (subtle; not derived for remotes) 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 static readonly FastAnimatorParameter k_InCombat = new FastAnimatorParameter("InCombat"); // 07-18: Menacing01 combat idle -- swing recency (FeelConfig.CombatIdleHoldSec) // Ticks after a swing-start that IsAttacking stays true (drives the MeleeSwing state). Kept < the swing lock // (MeleeRecoverTicks ~30) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State // transition per hit. ~0.43s @ 60Hz (07-19 heavy retime). Presentation-only. const uint k_AttackAnimTicks = PlayerAimSystem.CastFacingTicks; // structurally tied (07-16 review): body-yaw cast-turn and the swing anim pulse must agree // Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn). NativeParallelHashMap _prevPos; // 07-16 banking: previous local-player facing + the smoothed bank value (main-thread state; one entity). float2 _prevLocalFacing; bool _bankInit; float _bankSmoothed; 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 = default, interpTick = default; if (SystemAPI.TryGetSingleton(out var nt)) { serverTick = nt.ServerTick; // predicted: local owner interpTick = nt.InterpolationTick.IsValid ? nt.InterpolationTick : nt.ServerTick; // interpolated: remotes } // 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; // 07-18 combat idle: swing-recency window (the same wrap-safe SwingActive predicate as the anim pulse, wider). 60Hz sim ticks. uint combatIdleTicks = (uint)math.max(1f, FeelConfig.CombatIdleHoldSec * 60f); // 07-16 banking: signed facing turn rate -> Bank (-1..1, + = bank right, leaning INTO the turn). // Sampled main-thread (a single local player), smoothed so the additive pose eases in and out. float bankTarget = 0f; foreach (var facingRO in SystemAPI.Query>().WithAll()) { float2 f = facingRO.ValueRO.Direction; if (_bankInit && math.lengthsq(f) > 1e-6f && math.lengthsq(_prevLocalFacing) > 1e-6f) { float2 a2 = math.normalize(_prevLocalFacing); float2 b2 = math.normalize(f); float signed = math.atan2(a2.x * b2.y - a2.y * b2.x, math.clamp(math.dot(a2, b2), -1f, 1f)); float degPerSec = math.degrees(signed) / dt; bankTarget = math.clamp(-degPerSec / math.max(30f, FeelConfig.BankFullTurnDegPerSec), -1f, 1f); } _prevLocalFacing = f; _bankInit = true; break; } _bankSmoothed = math.lerp(_bankSmoothed, bankTarget, 1f - math.exp(-6f * dt)); // --- 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, bank = k_Bank, bankValue = _bankSmoothed, inCombat = k_InCombat, combatIdleTicks = combatIdleTicks, strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed), runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed), }; Dependency = localJob.ScheduleParallel(Dependency); // --- REMOTE players (position-delta). Single-threaded write to the shared prevPos cache. --- // Remote teammates are INTERPOLATED ghosts: their replicated MeleeCombo / SocketCooldown arrive on the // interpolation timeline, so their swing windows must be timed against InterpolationTick. Using the // predicted ServerTick here desynced a teammate's swing animation from their damage by ~RTT/2 + // interp buffer — invisible on loopback (audit finding M1). The LOCAL job above stays on ServerTick: // the owning player IS predicted. 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 = interpTick, attackTicks = k_AttackAnimTicks, dt = dt, strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed), runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed), inCombat = k_InCombat, combatIdleTicks = combatIdleTicks, 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]); } // The swing/fire tick-window predicates (SwingActive/FireActive/SocketFireAndCone) moved to // TickWindowMath (Simulation/Combat) 07-15 — shared verbatim with the predicted facing path // (PlayerAimSystem); this system passes k_AttackAnimTicks at the call sites. // 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 bank; public float bankValue; public FastAnimatorParameter inCombat; public uint combatIdleTicks; public FastAnimatorParameter strideScale; public float trudgeNaturalSpeed; public float runNaturalSpeed; 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 SocketCooldown socketCd, [ReadOnly] DynamicBuffer sockets, [ReadOnly] DynamicBuffer effSockets, 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, TickWindowMath.SwingActive(melee, serverTick, attackTicks)); TickWindowMath.SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingL, out bool coneL); if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingL); // 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(bank)) a.SetParameterValue(bank, bankValue); if (a.HasParameter(strideScale)) a.SetParameterValue(strideScale, StrideScaleValue(math.length(body.RelativeVelocity.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed)); if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step); if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL); if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks)); } } // 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 FastAnimatorParameter strideScale; public float trudgeNaturalSpeed; public float runNaturalSpeed; public NativeParallelHashMap prevPos; public NativeParallelHashSet seen; public FastAnimatorParameter inCombat; public uint combatIdleTicks; void Execute( Entity e, AnimatorControllerParameterIndexTableComponent indexTable, DynamicBuffer parametersArr, in LocalTransform xform, in PlayerFacing facing, in EffectiveCharacterStats stats, in MeleeCombo melee, in SocketCooldown socketCd, [ReadOnly] DynamicBuffer sockets, [ReadOnly] DynamicBuffer effSockets, 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, TickWindowMath.SwingActive(melee, serverTick, attackTicks)); TickWindowMath.SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingR, out bool coneR); if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingR); if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, false); // DashState is not replicated to remotes if (a.HasParameter(strideScale)) a.SetParameterValue(strideScale, StrideScaleValue(math.length(vel.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed)); if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step); if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR); if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks)); } } // 07-16e: playback correction against the walk→run BLENDED natural — the two-ring gait tree picks // the clip; this keeps residual cadence honest between/beyond the rings. Pure, Burst-safe. static float StrideScaleValue(float planarSpeed, float maxSpeed, float walkNatural, float runNatural) { float maxSp = math.max(0.5f, maxSpeed); float walkRing = walkNatural / maxSp; float t = math.saturate((planarSpeed / maxSp - walkRing) / math.max(0.05f, 1f - walkRing)); float natural = math.lerp(walkNatural, runNatural, t); return math.clamp(planarSpeed / math.max(0.3f, natural), 0.6f, 2.2f); } // 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); } } }