Sync per-pose index and locomotion state across the network

Add PlayerAnimationSync NetworkBehaviour with owner-authoritative
NetworkVariables for pose index and locomotion category (idle/walk/run/fall).
Remote avatars switch their PlayerPoseController into network-controlled mode,
skipping local input, sprint reset, capsule resize and camera framing, and
take pose index plus locomotion externally. Disable pose persistence on
network-driven remotes so foreign avatars can't overwrite the local save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-27 14:44:11 +02:00
1 parent 4fa443f630
commit 7f2f4e5999
5 files changed
+186 -29

No files matched your search

+14
View File
@@ -48,6 +48,7 @@ GameObject:
- component: {fileID: 1557561356852236266}
- component: {fileID: 6847253018511389861}
- component: {fileID: 8574096554986372233}
- component: {fileID: 8646586982149381366}
m_Layer: 0
m_Name: Player
m_TagString: Player
@@ -296,6 +297,19 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::NetworkPlayerSetup
ShowTopMostFoldoutHeaderGroup: 1
--- !u!114 &8646586982149381366
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5384024074013944205}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8c52bb7282c319e4f8c1f956d17cbdca, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::PlayerAnimationSync
ShowTopMostFoldoutHeaderGroup: 1
--- !u!1 &6840099669803454162
GameObject:
m_ObjectHideFlags: 0
+70
View File
@@ -0,0 +1,70 @@
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Replicates the pose system's animation state across the network. The owner publishes its selected
/// pose index and resolved locomotion category (idle / walk / run / fall); remote avatars switch their
/// <see cref="PlayerPoseController"/> to network-driven mode and play the matching clip, so a remote
/// player shows the same pose and walk/run/fall animation the owner sees — not just a sliding idle.
///
/// Position/rotation come from the NetworkTransform; this only carries the small animation state. Both
/// values are owner-authoritative and change rarely (a pose cycle, a locomotion transition), so the
/// NetworkVariables send a delta only when the value actually changes.
///
/// Runs after the PlayerPoseController (execution order 100) so the owner reads this frame's resolved
/// state; remote avatars apply via OnValueChanged, independent of frame order.
/// </summary>
[RequireComponent(typeof(PlayerPoseController))]
[DisallowMultipleComponent]
[DefaultExecutionOrder(200)]
public class PlayerAnimationSync : NetworkBehaviour
{
private readonly NetworkVariable<int> _poseIndex =
new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
// Locomotion category stored as a byte (PlayerPoseController.Locomotion has only a handful of values).
private readonly NetworkVariable<byte> _locomotion =
new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
private PlayerPoseController _pose;
void Awake() => _pose = GetComponent<PlayerPoseController>();
public override void OnNetworkSpawn()
{
if (IsOwner)
return;
// Remote avatar: stop reading local input/physics and drive the pose from replicated state.
_pose.EnableNetworkDrive();
_poseIndex.OnValueChanged += OnPoseIndexChanged;
_locomotion.OnValueChanged += OnLocomotionChanged;
ApplyRemote(); // spawn-time values are already replicated
}
public override void OnNetworkDespawn()
{
_poseIndex.OnValueChanged -= OnPoseIndexChanged;
_locomotion.OnValueChanged -= OnLocomotionChanged;
}
void Update()
{
if (!IsOwner || !_pose)
return;
// Publish the owner's resolved animation state. Assigning the same value is a no-op for
// replication (the NetworkVariable dirties only on an actual change), but guard anyway to keep
// intent clear.
int index = _pose.CurrentIndex;
byte loco = (byte)_pose.CurrentLocomotion;
if (_poseIndex.Value != index) _poseIndex.Value = index;
if (_locomotion.Value != loco) _locomotion.Value = loco;
}
private void OnPoseIndexChanged(int previous, int current) => ApplyRemote();
private void OnLocomotionChanged(byte previous, byte current) => ApplyRemote();
private void ApplyRemote() =>
_pose.SetNetworkState(_poseIndex.Value, (PlayerPoseController.Locomotion)_locomotion.Value);
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c52bb7282c319e4f8c1f956d17cbdca
@@ -62,7 +62,9 @@ public partial class PlayerPoseController
/// <summary>Writes the current pose index to disk.</summary>
public void SavePoseState()
{
if (!persistPose) return;
// A network-driven remote avatar shares the local "Player(Clone)" save key with the owned avatar;
// persisting it would clobber the local player's saved pose with a replicated one.
if (_networkControlled || !persistPose) return;
try
{
@@ -79,7 +81,9 @@ public partial class PlayerPoseController
/// <summary>Restores the saved pose index, or does nothing if there is no save.</summary>
private void RestorePoseState()
{
if (!persistPose) return;
// Skip on a network-driven remote avatar: its pose is replicated from the owner, and reading the
// local save would briefly show the wrong pose before the first network update arrives.
if (_networkControlled || !persistPose) return;
var state = LoadPoseState();
if (state == null) return;
+94 -27
View File
@@ -80,6 +80,18 @@ public partial class PlayerPoseController : MonoBehaviour
private int _currentIndex;
/// <summary>Locomotion category that selects which clip the active pose plays.</summary>
public enum Locomotion { Idle, Walk, Run, Fall }
// Network drive: a remote avatar's PlayerCharacterController and CharacterController are disabled
// (NetworkTransform owns its transform), so its movement-state flags are dead. Instead of reading
// local input/physics, such an avatar takes its pose index and locomotion category from the owner
// via SetNetworkState (pushed by PlayerAnimationSync). _currentLocomotion is the category resolved
// this frame, published by the owner for replication.
private bool _networkControlled;
private Locomotion _networkLoco;
private Locomotion _currentLocomotion;
// Playables: a 2-input AnimationMixerPlayable that overrides the Animator Controller. Input 1 holds
// the current ("to") pose; input 0 holds the outgoing ("from") pose during a crossfade and is empty
// once the blend completes. Blending both inputs over a per-frame rest baseline (see below) makes
@@ -121,6 +133,26 @@ public partial class PlayerPoseController : MonoBehaviour
/// <summary>Index of the currently selected pose.</summary>
public int CurrentIndex => _currentIndex;
/// <summary>Locomotion category resolved this frame (which clip the active pose is playing). The owner publishes it for network sync.</summary>
public Locomotion CurrentLocomotion => _currentLocomotion;
/// <summary>True once this avatar is driven by replicated network state instead of local input/physics.</summary>
public bool NetworkControlled => _networkControlled;
/// <summary>
/// Switches this avatar to network-driven animation: local input, the sprint-reset, the per-pose
/// collision capsule and camera framing are all skipped; pose index and locomotion are supplied by
/// <see cref="SetNetworkState"/>. Called on a remote avatar by <see cref="PlayerAnimationSync"/>.
/// </summary>
public void EnableNetworkDrive() => _networkControlled = true;
/// <summary>Pushes the replicated pose index and locomotion category onto a network-driven avatar.</summary>
public void SetNetworkState(int poseIndex, Locomotion loco)
{
_currentIndex = WrapIndex(poseIndex);
_networkLoco = loco;
}
/// <summary>Eased per-pose y-offset for the first-person eye, applied on top of the camera target by the FirstPersonController.</summary>
public float FirstPersonCameraYOffset => _firstPersonCameraOffset;
@@ -137,40 +169,36 @@ public partial class PlayerPoseController : MonoBehaviour
void Update()
{
if (CycleKeyPressed())
NextPose();
// Local control: the cycle key and the sprint reset choose the pose. A network-driven (remote)
// avatar skips this — its pose index is pushed in from the owner via SetNetworkState.
if (!_networkControlled)
{
if (CycleKeyPressed())
NextPose();
// Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default
// pose. Held continuously below, so the reset shows up immediately, not only once back on the floor.
if (_player.IsSprinting)
_currentIndex = WrapIndex(defaultPoseIndex);
// Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default
// pose. Held continuously, so the reset shows up immediately, not only once back on the floor.
if (_player.IsSprinting)
_currentIndex = WrapIndex(defaultPoseIndex);
}
PlayerPose pose = ActivePose();
// Pick the clip for the current locomotion state. Standing still, on a wall or on the ceiling
// holds the idle pose; moving on the ground plays the run clip while sprinting or the walk clip
// otherwise; falling (airborne and descending past the controller's threshold) plays the fall
// clip. All three extra clips are optional — a null one falls back to the idle pose, so an unset
// clip keeps the held-pose behaviour. PlayPose crossfades on any change and hands the rig back
// Resolve the locomotion category (idle / walk / run / fall) and pick its clip. Locally the
// category comes from the controller's movement state; on a remote avatar it is the replicated
// value. It is stored so the owner's PlayerAnimationSync can publish it. ClipFor falls back to
// the idle clip for an unset optional clip, so a remote avatar (same pose asset) resolves to
// exactly the clip the owner shows; PlayPose crossfades on any change and hands the rig back
// only when there is no clip at all (empty list / null idle clip).
AnimationClip clip = pose ? pose.Clip : null;
float speed = pose ? pose.ClipSpeed : 1f;
if (pose && _player.IsMovingOnGround)
{
AnimationClip locomotion = _player.IsSprinting ? pose.RunClip : pose.WalkClip;
if (locomotion)
{
clip = locomotion;
speed = _player.IsSprinting ? pose.RunClipSpeed : pose.WalkClipSpeed;
}
}
else if (pose && _player.IsFalling && pose.FallClip)
{
clip = pose.FallClip;
speed = pose.FallClipSpeed;
}
_currentLocomotion = _networkControlled ? _networkLoco : ResolveLocalLocomotion();
AnimationClip clip = ClipFor(pose, _currentLocomotion, out float speed);
PlayPose(clip, speed);
// Collision capsule and camera framing are local-owner concerns: a remote avatar's controller and
// CharacterController are disabled and no camera tracks it, so a network-driven avatar stops here.
if (_networkControlled)
return;
// CharacterController capsule: each pose carries its own center Y / radius / height so a low pose
// (ball, star) can crawl where the default standing capsule is too tall. Eased toward the active
// pose's dimensions when the pose changes so the collider resizes smoothly instead of popping; a
@@ -235,6 +263,45 @@ public partial class PlayerPoseController : MonoBehaviour
return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)];
}
/// <summary>Maps the controller's current movement state to a locomotion category for clip selection.</summary>
private Locomotion ResolveLocalLocomotion()
{
if (_player.IsMovingOnGround)
return _player.IsSprinting ? Locomotion.Run : Locomotion.Walk;
if (_player.IsFalling)
return Locomotion.Fall;
return Locomotion.Idle;
}
/// <summary>
/// Returns the clip (and its <paramref name="speed"/>) for <paramref name="pose"/> in the given
/// locomotion category. The walk/run/fall clips are optional: an unset one falls back to the pose's
/// idle clip, so an unauthored category keeps the held-pose behaviour identically on the owner and on
/// a network-driven remote avatar. A null pose hands the rig back (no clip).
/// </summary>
private AnimationClip ClipFor(PlayerPose pose, Locomotion loco, out float speed)
{
if (pose)
{
switch (loco)
{
case Locomotion.Walk when pose.WalkClip:
speed = pose.WalkClipSpeed;
return pose.WalkClip;
case Locomotion.Run when pose.RunClip:
speed = pose.RunClipSpeed;
return pose.RunClip;
case Locomotion.Fall when pose.FallClip:
speed = pose.FallClipSpeed;
return pose.FallClip;
}
speed = pose.ClipSpeed;
return pose.Clip;
}
speed = 1f;
return null;
}
/// <summary>
/// Eases the CharacterController capsule toward the given target dimensions and writes them through
/// the controller. A single normalized blend drives all three values together, so the capsule stays a