diff --git a/Assets/Prefabs/Player.prefab b/Assets/Prefabs/Player.prefab
index 0e76bc0..2fb0e3b 100644
--- a/Assets/Prefabs/Player.prefab
+++ b/Assets/Prefabs/Player.prefab
@@ -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
diff --git a/Assets/Scripts/PlayerAnimationSync.cs b/Assets/Scripts/PlayerAnimationSync.cs
new file mode 100644
index 0000000..494d0fb
--- /dev/null
+++ b/Assets/Scripts/PlayerAnimationSync.cs
@@ -0,0 +1,70 @@
+using Unity.Netcode;
+using UnityEngine;
+
+///
+/// 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
+/// 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.
+///
+[RequireComponent(typeof(PlayerPoseController))]
+[DisallowMultipleComponent]
+[DefaultExecutionOrder(200)]
+public class PlayerAnimationSync : NetworkBehaviour
+{
+ private readonly NetworkVariable _poseIndex =
+ new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
+
+ // Locomotion category stored as a byte (PlayerPoseController.Locomotion has only a handful of values).
+ private readonly NetworkVariable _locomotion =
+ new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
+
+ private PlayerPoseController _pose;
+
+ void Awake() => _pose = GetComponent();
+
+ 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);
+}
diff --git a/Assets/Scripts/PlayerAnimationSync.cs.meta b/Assets/Scripts/PlayerAnimationSync.cs.meta
new file mode 100644
index 0000000..597faa2
--- /dev/null
+++ b/Assets/Scripts/PlayerAnimationSync.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8c52bb7282c319e4f8c1f956d17cbdca
\ No newline at end of file
diff --git a/Assets/Scripts/PlayerPoseController.Persistence.cs b/Assets/Scripts/PlayerPoseController.Persistence.cs
index d30ee1b..19e2cf7 100644
--- a/Assets/Scripts/PlayerPoseController.Persistence.cs
+++ b/Assets/Scripts/PlayerPoseController.Persistence.cs
@@ -62,7 +62,9 @@ public partial class PlayerPoseController
/// Writes the current pose index to disk.
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
/// Restores the saved pose index, or does nothing if there is no save.
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;
diff --git a/Assets/Scripts/PlayerPoseController.cs b/Assets/Scripts/PlayerPoseController.cs
index fe496b9..5029f4a 100644
--- a/Assets/Scripts/PlayerPoseController.cs
+++ b/Assets/Scripts/PlayerPoseController.cs
@@ -80,6 +80,18 @@ public partial class PlayerPoseController : MonoBehaviour
private int _currentIndex;
+ /// Locomotion category that selects which clip the active pose plays.
+ 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
/// Index of the currently selected pose.
public int CurrentIndex => _currentIndex;
+ /// Locomotion category resolved this frame (which clip the active pose is playing). The owner publishes it for network sync.
+ public Locomotion CurrentLocomotion => _currentLocomotion;
+
+ /// True once this avatar is driven by replicated network state instead of local input/physics.
+ public bool NetworkControlled => _networkControlled;
+
+ ///
+ /// 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
+ /// . Called on a remote avatar by .
+ ///
+ public void EnableNetworkDrive() => _networkControlled = true;
+
+ /// Pushes the replicated pose index and locomotion category onto a network-driven avatar.
+ public void SetNetworkState(int poseIndex, Locomotion loco)
+ {
+ _currentIndex = WrapIndex(poseIndex);
+ _networkLoco = loco;
+ }
+
/// Eased per-pose y-offset for the first-person eye, applied on top of the camera target by the FirstPersonController.
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)];
}
+ /// Maps the controller's current movement state to a locomotion category for clip selection.
+ private Locomotion ResolveLocalLocomotion()
+ {
+ if (_player.IsMovingOnGround)
+ return _player.IsSprinting ? Locomotion.Run : Locomotion.Walk;
+ if (_player.IsFalling)
+ return Locomotion.Fall;
+ return Locomotion.Idle;
+ }
+
+ ///
+ /// Returns the clip (and its ) for 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).
+ ///
+ 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;
+ }
+
///
/// 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