using FishNet.Component.Transforming; using FishNet.Connection; using FishNet.Object; using UnityEngine; using UnityEngine.InputSystem; namespace Agame { /// /// 玩家移动控制 —— FishNet 客户端授权模式。 /// 参考:Docs/联机方案选型.md §3.3,以及官方教程 "Moving Your Player Around"。 /// /// 职责边界:本组件只负责「读输入 + 驱动 CharacterController」。 /// 位置/旋转的同步交给 Player 预制体上的 NetworkTransform(Client Authoritative), /// 这里不写任何 RPC / SyncVar。 /// /// FishNet 4.7.3 下的三条硬约束: /// 1. 必须继承 NetworkBehaviour 而非 MonoBehaviour,否则拿不到 IsOwner 与网络生命周期回调。 /// 2. IsOwner 禁止在 Awake / Start / OnStartServer / OnStartNetwork 里访问 —— /// FishNet 代码生成器会直接抛编译错误,按拥有者初始化的逻辑要放在 OnStartClient。 /// 3. 只有拥有者能驱动自己的对象,少了这道判断就会去移动别人的玩家。 /// [RequireComponent(typeof(CharacterController))] public class PlayerController : NetworkBehaviour { [Header("Movement")] [SerializeField, Min(0f)] private float moveSpeed = 5f; [SerializeField, Min(0f)] private float sprintSpeed = 8f; [Header("Gravity & Jump")] [Tooltip("负值向下。")] [SerializeField] private float gravity = -20f; [SerializeField, Min(0f)] private float jumpHeight = 1.5f; [Header("Ownership")] [Tooltip("仅本地拥有者需要启用的对象(玩家相机等)。非拥有者会被自动禁用,避免多相机抢占渲染。")] [SerializeField] private GameObject[] ownerOnlyObjects; private CharacterController _controller; private Vector3 _verticalVelocity; private void Awake() { // Awake 中不得访问 IsOwner,这里只做纯本地缓存。 _controller = GetComponent(); } public override void OnStartClient() { base.OnStartClient(); // 用 OnStartClient 而不是 Awake/Start:走到这里说明对象已完成网络链接、 // 所有权已分配,此时 IsOwner 才是可信的。 ApplyOwnership(); WarnIfTransformNotSynchronized(); } public override void OnOwnershipClient(NetworkConnection prevOwner) { base.OnOwnershipClient(prevOwner); // 所有权可能中途变更(例如将来的主机迁移),相机等要跟着切换。 ApplyOwnership(); } private void Update() { // 只驱动本地拥有者,否则每个客户端都会去移动别人的玩家。 if (!IsOwner) return; Keyboard kb = Keyboard.current; if (kb == null) return; float speed = kb.leftShiftKey.isPressed ? sprintSpeed : moveSpeed; Vector3 moveDirection = ReadMoveDirection(kb); _controller.Move(moveDirection * (speed * Time.deltaTime)); UpdateJumpAndGravity(kb); } /// /// 读取 WASD 并转换为相对于角色自身朝向的方向(body 的偏航由 MouseLook 负责)。 /// 斜向输入做归一化,避免对角线移动比正向更快。 /// private Vector3 ReadMoveDirection(Keyboard kb) { float moveX = 0f; float moveZ = 0f; if (kb.aKey.isPressed) moveX -= 1f; if (kb.dKey.isPressed) moveX += 1f; if (kb.sKey.isPressed) moveZ -= 1f; if (kb.wKey.isPressed) moveZ += 1f; Vector3 direction = transform.right * moveX + transform.forward * moveZ; return Vector3.ClampMagnitude(direction, 1f); } private void UpdateJumpAndGravity(Keyboard kb) { bool isGrounded = _controller.isGrounded; // 贴地时保留一点向下的力,防止 isGrounded 在斜坡/台阶上反复抖动。 if (isGrounded && _verticalVelocity.y < 0f) _verticalVelocity.y = -2f; if (isGrounded && kb.spaceKey.wasPressedThisFrame) { // 由目标跳跃高度反推初速度:v = sqrt(2 * g * h) _verticalVelocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); } _verticalVelocity.y += gravity * Time.deltaTime; _controller.Move(_verticalVelocity * Time.deltaTime); } /// /// 按所有权开关"只属于本地玩家"的对象。最典型的是玩家相机: /// 若每个客户端的相机都常驻启用,N 个玩家就会互相抢渲染。 /// private void ApplyOwnership() { if (ownerOnlyObjects == null) return; foreach (GameObject target in ownerOnlyObjects) { if (target != null) target.SetActive(IsOwner); } } /// /// 缺少 NetworkTransform 时位置不会同步,但画面上"自己能走",极易误判成逻辑没问题。 /// 这里主动报一次,省掉对着两个人不同步干瞪眼的时间。 /// private void WarnIfTransformNotSynchronized() { if (!IsOwner) return; if (GetComponent() == null) { Debug.LogWarning( $"[{nameof(PlayerController)}] {name} 上没有 NetworkTransform,位置不会同步到其他客户端。" + "请在 Player 预制体上加 NetworkTransform,并开启 Client Authoritative 与 Synchronize Position。", this); } } } }