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,77 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
public class AnimParamMathTests
{
const float Eps = 1e-3f;
static readonly float2 Fwd = new float2(0f, 1f); // facing world +Z
const float Max = 5f;
[Test] public void ZeroVelocity_AllZero()
{
var r = AnimParamMath.LocomotionParams(float3.zero, Fwd, Max);
Assert.AreEqual(0f, r.x, Eps); Assert.AreEqual(0f, r.y, Eps); Assert.AreEqual(0f, r.z, Eps);
}
[Test] public void FullForward_MoveZ1_Speed1()
{
var r = AnimParamMath.LocomotionParams(new float3(0f, 0f, Max), Fwd, Max);
Assert.AreEqual(0f, r.x, Eps); Assert.AreEqual(1f, r.y, Eps); Assert.AreEqual(1f, r.z, Eps);
}
[Test] public void FullBackward_MoveZNeg1()
{
var r = AnimParamMath.LocomotionParams(new float3(0f, 0f, -Max), Fwd, Max);
Assert.AreEqual(0f, r.x, Eps); Assert.AreEqual(-1f, r.y, Eps); Assert.AreEqual(1f, r.z, Eps);
}
[Test] public void StrafeRight_MoveX1()
{
// facing +Z -> right = (+X). world velocity +X -> MoveX = +1.
var r = AnimParamMath.LocomotionParams(new float3(Max, 0f, 0f), Fwd, Max);
Assert.AreEqual(1f, r.x, Eps); Assert.AreEqual(0f, r.y, Eps); Assert.AreEqual(1f, r.z, Eps);
}
[Test] public void StrafeLeft_MoveXNeg1()
{
var r = AnimParamMath.LocomotionParams(new float3(-Max, 0f, 0f), Fwd, Max);
Assert.AreEqual(-1f, r.x, Eps);
}
[Test] public void HalfForward_HalfSpeed()
{
var r = AnimParamMath.LocomotionParams(new float3(0f, 0f, Max * 0.5f), Fwd, Max);
Assert.AreEqual(0.5f, r.y, Eps); Assert.AreEqual(0.5f, r.z, Eps);
}
[Test] public void OverMax_SpeedClampsTo1()
{
var r = AnimParamMath.LocomotionParams(new float3(0f, 0f, Max * 3f), Fwd, Max);
Assert.AreEqual(1f, r.z, Eps); Assert.AreEqual(1f, r.y, Eps);
}
[Test] public void RotatedFacing_ProjectsIntoFrame()
{
// facing world +X; velocity world +X should read as forward (MoveZ+), not strafe.
var r = AnimParamMath.LocomotionParams(new float3(Max, 0f, 0f), new float2(1f, 0f), Max);
Assert.AreEqual(0f, r.x, Eps); Assert.AreEqual(1f, r.y, Eps);
}
[Test] public void DegenerateFacing_DefaultsToWorldForward()
{
var r = AnimParamMath.LocomotionParams(new float3(0f, 0f, Max), float2.zero, Max);
Assert.AreEqual(1f, r.y, Eps); // treated as facing +Z
}
[Test] public void ZeroMaxSpeed_NoNaN_NoDivByZero()
{
// guard: safeMax = max(maxSpeed, 1e-4) -> finite output even with maxSpeed 0.
var r = AnimParamMath.LocomotionParams(new float3(0f, 0f, 1f), Fwd, 0f);
Assert.IsFalse(float.IsNaN(r.x) || float.IsNaN(r.y) || float.IsNaN(r.z));
Assert.AreEqual(1f, r.z, Eps); // clamps to 1
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f861fc391a6fa9e4ab9293cd489f9df1