移动同步

This commit is contained in:
2026-09-20 15:54:34 +08:00
parent 3298ccdc76
commit d2ec2f6f0e
7 changed files with 288 additions and 166 deletions
+43 -26
View File
@@ -3,65 +3,82 @@ using UnityEngine.InputSystem;
namespace Agame
{
/// <summary>
/// 第一人称视角控制:鼠标横移带动 body 偏航,纵移带动相机俯仰。
///
/// 网络侧前提(重要):本组件挂在 PlayerCamera 上,而 PlayerCamera 只会被
/// <see cref="PlayerController"/> 为「本地拥有者」启用(见其 ApplyOwnership)。
/// 非拥有者客户端的相机是 disabled 的,因此这里不会执行,天然不需要再判所有权。
///
/// 反过来说:如果日后把 PlayerCamera 改成常驻启用,必须补上所有权判断,
/// 否则每个客户端都会用自己的鼠标去转动别人玩家的视角。
/// </summary>
public class MouseLook : MonoBehaviour
{
[Header("Sensitivity")]
[Tooltip("Degrees of rotation per pixel of mouse movement.")]
public float mouseSensitivity = 0.12f;
[SerializeField] private float mouseSensitivity = 0.12f;
[Header("Vertical Clamp")]
public float minLookAngle = -80f;
public float maxLookAngle = 80f;
[SerializeField] private float minLookAngle = -80f;
[SerializeField] private float maxLookAngle = 80f;
[Header("References")]
public Transform characterBody;
[Tooltip("被偏航(Horizontal)的躯体 Transform,通常是玩家根节点。")]
[SerializeField] private Transform characterBody;
private float xRotation = 0f;
private float _xRotation;
private bool _warnedMissingBody;
private void Start()
private void OnEnable()
{
// 跟随本对象的启用状态锁定光标:只有本地拥有者的相机会被启用,
// 所以光标锁定/释放自然就只在拥有者这一侧发生。
LockCursor(true);
}
private void OnDisable()
{
// 对象被禁用(非拥有者,或玩家已下线)时务必释放光标,否则会锁死在别人的机器上。
LockCursor(false);
}
private void Update()
{
var kb = Keyboard.current;
var mouse = Mouse.current;
Mouse mouse = Mouse.current;
if (mouse == null)
{
return;
}
// Esc releases the cursor, left click grabs it again (editor-friendly)
// Esc 释放光标,左键重新抓取(方便编辑器里切出去)。
Keyboard kb = Keyboard.current;
if (kb != null && kb.escapeKey.wasPressedThisFrame)
{
LockCursor(false);
}
if (mouse.leftButton.wasPressedThisFrame && Cursor.lockState != CursorLockMode.Locked)
{
LockCursor(true);
}
if (Cursor.lockState != CursorLockMode.Locked)
{
return;
}
// Mouse delta is in pixels for the new Input System
// 新输入系统下 delta 的单位是像素,不是轴值。
Vector2 delta = mouse.delta.ReadValue();
float mouseX = delta.x * mouseSensitivity;
float mouseY = delta.y * mouseSensitivity;
// Yaw the body left/right
// 横移:转动躯体
if (characterBody != null)
{
characterBody.Rotate(Vector3.up * mouseX);
characterBody.Rotate(Vector3.up * (delta.x * mouseSensitivity));
}
else if (!_warnedMissingBody)
{
_warnedMissingBody = true;
Debug.LogWarning(
$"[{nameof(MouseLook)}] 未指定 Character Body,水平视角不会生效。请指向玩家根节点的 Transform。",
this);
}
// Pitch the camera up/down
xRotation -= mouseY;
xRotation = Mathf.Clamp(xRotation, minLookAngle, maxLookAngle);
transform.localRotation = Quaternion.Euler(xRotation, 0f, 0f);
// 纵移:俯仰相机,并夹在上下限之间防止翻转
_xRotation = Mathf.Clamp(_xRotation - (delta.y * mouseSensitivity), minLookAngle, maxLookAngle);
transform.localRotation = Quaternion.Euler(_xRotation, 0f, 0f);
}
private void LockCursor(bool locked)
+120 -30
View File
@@ -1,64 +1,154 @@
using FishNet.Component.Transforming;
using FishNet.Connection;
using FishNet.Object;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Agame
{
/// <summary>
/// 玩家移动控制 —— 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. 只有拥有者能驱动自己的对象,少了这道判断就会去移动别人的玩家。
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class PlayerController : MonoBehaviour
public class PlayerController : NetworkBehaviour
{
[Header("Movement")]
public float moveSpeed = 5f;
public float sprintSpeed = 8f;
public float gravity = -20f;
public float jumpHeight = 1.5f;
[SerializeField, Min(0f)] private float moveSpeed = 5f;
[SerializeField, Min(0f)] private float sprintSpeed = 8f;
private CharacterController controller;
private Vector3 velocity;
private bool isGrounded;
[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()
{
controller = GetComponent<CharacterController>();
// Awake 中不得访问 IsOwner,这里只做纯本地缓存。
_controller = GetComponent<CharacterController>();
}
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()
{
var kb = Keyboard.current;
if (kb == null)
{
// 只驱动本地拥有者,否则每个客户端都会去移动别人的玩家。
if (!IsOwner)
return;
}
isGrounded = controller.isGrounded;
if (isGrounded && velocity.y < 0f)
{
velocity.y = -2f;
}
Keyboard kb = Keyboard.current;
if (kb == null)
return;
// WASD input
float speed = kb.leftShiftKey.isPressed ? sprintSpeed : moveSpeed;
Vector3 moveDirection = ReadMoveDirection(kb);
_controller.Move(moveDirection * (speed * Time.deltaTime));
UpdateJumpAndGravity(kb);
}
/// <summary>
/// 读取 WASD 并转换为相对于角色自身朝向的方向(body 的偏航由 MouseLook 负责)。
/// 斜向输入做归一化,避免对角线移动比正向更快。
/// </summary>
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;
// Move relative to the capsule's own facing (which MouseLook rotates)
Vector3 move = transform.right * moveX + transform.forward * moveZ;
move = Vector3.ClampMagnitude(move, 1f);
Vector3 direction = transform.right * moveX + transform.forward * moveZ;
return Vector3.ClampMagnitude(direction, 1f);
}
float speed = kb.leftShiftKey.isPressed ? sprintSpeed : moveSpeed;
controller.Move(move * speed * Time.deltaTime);
private void UpdateJumpAndGravity(Keyboard kb)
{
bool isGrounded = _controller.isGrounded;
// Jump
if (kb.spaceKey.wasPressedThisFrame && isGrounded)
// 贴地时保留一点向下的力,防止 isGrounded 在斜坡/台阶上反复抖动。
if (isGrounded && _verticalVelocity.y < 0f)
_verticalVelocity.y = -2f;
if (isGrounded && kb.spaceKey.wasPressedThisFrame)
{
velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
// 由目标跳跃高度反推初速度:v = sqrt(2 * g * h)
_verticalVelocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
// Gravity
velocity.y += gravity * Time.deltaTime;
controller.Move(velocity * Time.deltaTime);
_verticalVelocity.y += gravity * Time.deltaTime;
_controller.Move(_verticalVelocity * Time.deltaTime);
}
/// <summary>
/// 按所有权开关"只属于本地玩家"的对象。最典型的是玩家相机:
/// 若每个客户端的相机都常驻启用,N 个玩家就会互相抢渲染。
/// </summary>
private void ApplyOwnership()
{
if (ownerOnlyObjects == null)
return;
foreach (GameObject target in ownerOnlyObjects)
{
if (target != null)
target.SetActive(IsOwner);
}
}
/// <summary>
/// 缺少 NetworkTransform 时位置不会同步,但画面上"自己能走",极易误判成逻辑没问题。
/// 这里主动报一次,省掉对着两个人不同步干瞪眼的时间。
/// </summary>
private void WarnIfTransformNotSynchronized()
{
if (!IsOwner)
return;
if (GetComponent<NetworkTransform>() == null)
{
Debug.LogWarning(
$"[{nameof(PlayerController)}] {name} 上没有 NetworkTransform,位置不会同步到其他客户端。" +
"请在 Player 预制体上加 NetworkTransform,并开启 Client Authoritative 与 Synchronize Position。",
this);
}
}
}
}