Animate the player: Rukhanka skeletal locomotion (client-derived)

Replace the capsule with a rigged Synty SciFiSpace soldier driven by Rukhanka 2.9 (netcode replication off; animation derived client-side from replicated state). Adds a slim top-down AnimatorController (idle / 2D-strafe locomotion / death) from Synty clips; client-only PlayerAnimationDriveSystem (local CC-velocity + remote position-delta paths); AnimParamMath (+10 EditMode tests); ServerStripAnimationSystem (disables Rukhanka on the server, zero server-side animation). Client.asmdef gains Rukhanka.Runtime/CharacterController/Physics. EditMode 204/204; Play-validated. See DR-022.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:18:11 -07:00
parent 82adcd9357
commit 951b7ec273
23 changed files with 13599 additions and 61 deletions
@@ -0,0 +1,32 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure mapping from authoritative planar velocity + facing to client animation blend params.
/// Forward (along facing) -> MoveZ+, right of facing -> MoveX+ (matches the slim controller's 2D
/// strafe tree: +X=right, +Z=forward). EditMode-tested, no World needed. Burst-safe (no enums/strings).
/// </summary>
public static class AnimParamMath
{
/// <param name="planarVelocity">World velocity (KinematicCharacterBody.RelativeVelocity for the owner,
/// or LocalTransform position-delta/dt for remotes). Y is ignored.</param>
/// <param name="facing">Normalized world planar facing (PlayerFacing.Direction, world XZ as x,y). May be zero.</param>
/// <param name="maxSpeed">EffectiveCharacterStats.MoveSpeed (normalizer; guarded > 0).</param>
/// <returns>x = MoveX (strafe, -1..1), y = MoveZ (fwd/back, -1..1), z = Speed (0..1).</returns>
public static float3 LocomotionParams(float3 planarVelocity, float2 facing, float maxSpeed)
{
float2 vWorld = planarVelocity.xz;
float safeMax = math.max(maxSpeed, 1e-4f);
float speed = math.saturate(math.length(vWorld) / safeMax);
// Degenerate facing -> default to world +Z so the basis is well-defined.
float2 fwd = math.lengthsq(facing) > 1e-6f ? math.normalize(facing) : new float2(0f, 1f);
float2 right = new float2(fwd.y, -fwd.x); // 90deg clockwise in XZ (right-hand of forward)
float2 local = new float2(math.dot(vWorld, right), math.dot(vWorld, fwd)) / safeMax;
local = math.clamp(local, -1f, 1f);
return new float3(local.x, local.y, speed);
}
}
}