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,160 @@
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
using Unity.Transforms; // LocalTransform
using Unity.CharacterController; // KinematicCharacterBody
namespace ProjectM.Client
{
/// <summary>
/// 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).
///
/// 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). Cache prevPos per Entity, prune each frame.
/// </summary>
[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");
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
NativeParallelHashMap<Entity, float3> _prevPos;
protected override void OnCreate()
{
_prevPos = new NativeParallelHashMap<Entity, float3>(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;
// --- LOCAL owner (CC velocity) ---
var localJob = new LocalDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
};
Dependency = localJob.ScheduleParallel(Dependency);
// --- REMOTE players (position-delta). Single-threaded write to the shared prevPos cache. ---
var seen = new NativeParallelHashSet<Entity>(16, Allocator.TempJob);
var remoteJob = new RemoteDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
dt = dt,
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<Entity> 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]);
}
// LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent<Dead> 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 moveX, moveZ, speed, isDead;
void Execute(
AnimatorControllerParameterIndexTableComponent indexTable,
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
in PlayerFacing facing,
in EffectiveCharacterStats stats,
in KinematicCharacterBody body,
EnabledRefRO<Dead> 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);
}
}
// 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;
public float dt;
public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen;
void Execute(
Entity e,
AnimatorControllerParameterIndexTableComponent indexTable,
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
in LocalTransform xform,
in PlayerFacing facing,
in EffectiveCharacterStats stats,
EnabledRefRO<Dead> 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);
}
}
// 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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ec2539089c1135e4cbe1f320a604119a
@@ -12,7 +12,11 @@
"Unity.Entities.Graphics",
"Unity.InputSystem",
"Unity.Networking.Transport",
"UnityEngine.UI"
"UnityEngine.UI",
"Unity.CharacterController",
"Unity.Physics",
"Rukhanka.Runtime",
"Rukhanka.Toolbox"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -0,0 +1,37 @@
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Server
{
/// <summary>
/// Animation is a CLIENT-ONLY presentation concern (Rukhanka netcode OFF — see DR-022). Rukhanka still
/// touches the SERVER world: its deformation systems use [WorldSystemFilter(Default)] (Default includes
/// ServerSimulation), so a headless server would run skinned-mesh prep + mesh deformation on the player's
/// baked bones/meshes that nothing renders. (Rukhanka's bootstrap creates RukhankaAnimationSystemGroup on
/// the server too, but leaves it EMPTY — it only fills the update list for client/local worlds.)
///
/// This server-only one-shot disables every Rukhanka.Runtime system in the server world. Disabling a
/// ComponentSystemGroup stops all its children (managed AND unmanaged ISystems), so this covers the whole
/// Rukhanka stack — the server then does ZERO animation/deformation work. Matched by assembly name (no
/// hard Rukhanka type ref → no asmdef change), so it survives Rukhanka updates.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial class ServerStripAnimationSystem : SystemBase
{
protected override void OnUpdate()
{
int disabled = 0;
foreach (var sys in World.Systems)
{
if (sys == null || !sys.Enabled) continue;
if (sys.GetType().Assembly.GetName().Name == "Rukhanka.Runtime")
{
sys.Enabled = false;
disabled++;
}
}
Debug.Log($"[ProjectM] ServerStripAnimationSystem: disabled {disabled} Rukhanka systems on the server world (animation is client-only).");
Enabled = false; // one-shot
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eb468a67572121749b2ca104dbe8c476
@@ -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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 89e6fd18a1754b443afa2296d68033b2