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