Article

Unity 入门实战 02:角色移动与摄像机跟随(兼顾手感与架构)

路线阶段:Unity 入门实战第 2 章。
本章目标:实现“能玩且能扩展”的角色控制核心,而不是一次性 demo 控制器。

学习目标

完成本章后,你应该能做到:

  1. 将输入采样、移动逻辑、角色朝向拆分成独立模块。
  2. 实现帧率无关的移动与加速度控制。
  3. 搭建平滑摄像机跟随与旋转逻辑,避免眩晕和抖动。
  4. 保持输入层可替换(键鼠/手柄/触控)并兼容 WebGL。

常见错误起步方式

很多入门项目会这样写:

void Update()
{
    float h = Input.GetAxis("Horizontal");
    float v = Input.GetAxis("Vertical");
    transform.Translate(new Vector3(h, 0, v) * speed * Time.deltaTime);
    Camera.main.transform.position = transform.position + offset;
}

短期可跑,但会快速遇到问题:

  1. 输入、移动、镜头强耦合,后续难替换。
  2. 缺少加速/减速曲线,手感生硬。
  3. 镜头直接跟随导致抖动、穿模、眩晕。
  4. 无法支持锁定目标、冲刺、受击硬直等后续需求。

模块拆分方案

本章拆为四层:

  1. PlayerInputSampler:输入采样与归一化。
  2. PlayerMotor:速度积分、碰撞移动、朝向旋转。
  3. PlayerController:将输入意图转换为移动命令。
  4. FollowCameraRig:镜头平滑跟随与观察方向控制。

输入采样(可替换)

输入数据结构

public struct MoveInput
{
    public float X;
    public float Y;
    public bool Sprint;
}

采样器接口

public interface IPlayerInputSampler
{
    MoveInput Sample();
}

Unity 实现

public sealed class UnityPlayerInputSampler : IPlayerInputSampler
{
    public MoveInput Sample()
    {
        var input = new MoveInput();

        input.X = Input.GetAxisRaw("Horizontal");
        input.Y = Input.GetAxisRaw("Vertical");
        input.Sprint = Input.GetKey(KeyCode.LeftShift);

        var mag = Mathf.Sqrt(input.X * input.X + input.Y * input.Y);
        if (mag > 1f)
        {
            input.X /= mag;
            input.Y /= mag;
        }

        return input;
    }
}

归一化是必要步骤,避免对角线移动速度异常。

角色移动核心(Motor)

参数配置

[Serializable]
public sealed class MotorConfig
{
    public float WalkSpeed = 4.5f;
    public float SprintSpeed = 7.0f;
    public float Acceleration = 25f;
    public float Deceleration = 30f;
    public float RotationSpeed = 540f;
}

Motor 实现

public sealed class PlayerMotor
{
    private readonly CharacterController _cc;
    private readonly Transform _transform;
    private readonly MotorConfig _cfg;

    private Vector3 _velocity;

    public PlayerMotor(CharacterController cc, Transform transform, MotorConfig cfg)
    {
        _cc = cc;
        _transform = transform;
        _cfg = cfg;
    }

    public void Tick(Vector2 moveInput, bool sprint, float dt, Transform cameraForwardRef)
    {
        var camForward = cameraForwardRef.forward;
        camForward.y = 0f;
        camForward.Normalize();

        var camRight = cameraForwardRef.right;
        camRight.y = 0f;
        camRight.Normalize();

        var desiredDir = camForward * moveInput.y + camRight * moveInput.x;
        var desiredMag = Mathf.Clamp01(desiredDir.magnitude);

        if (desiredMag > 0.0001f)
        {
            desiredDir /= desiredMag;
        }

        var maxSpeed = sprint ? _cfg.SprintSpeed : _cfg.WalkSpeed;
        var desiredVelocity = desiredDir * maxSpeed * desiredMag;

        var accel = desiredMag > 0.01f ? _cfg.Acceleration : _cfg.Deceleration;
        _velocity = Vector3.MoveTowards(_velocity, desiredVelocity, accel * dt);

        _cc.Move(_velocity * dt);

        if (desiredMag > 0.1f)
        {
            RotateTowards(desiredDir, dt);
        }
    }

    private void RotateTowards(Vector3 dir, float dt)
    {
        var target = Quaternion.LookRotation(dir, Vector3.up);
        _transform.rotation = Quaternion.RotateTowards(_transform.rotation, target, _cfg.RotationSpeed * dt);
    }
}

控制层(Controller)

public sealed class PlayerController : MonoBehaviour
{
    [SerializeField] private CharacterController _characterController;
    [SerializeField] private Transform _cameraForwardRef;
    [SerializeField] private MotorConfig _config;

    private IPlayerInputSampler _input;
    private PlayerMotor _motor;

    private void Awake()
    {
        _input = new UnityPlayerInputSampler();
        _motor = new PlayerMotor(_characterController, transform, _config);
    }

    private void Update()
    {
        var sampled = _input.Sample();
        var move = new Vector2(sampled.X, sampled.Y);

        _motor.Tick(move, sampled.Sprint, Time.deltaTime, _cameraForwardRef);
    }
}

这样后续接手柄或虚拟摇杆时,只替换 IPlayerInputSampler 即可。

摄像机跟随(稳定 + 有手感)

参数配置

[Serializable]
public sealed class CameraFollowConfig
{
    public Vector3 Offset = new Vector3(0f, 6f, -7f);
    public float PositionSmoothTime = 0.12f;
    public float LookAtHeight = 1.5f;
    public float MaxDistance = 20f;
}

Follow Rig

public sealed class FollowCameraRig : MonoBehaviour
{
    [SerializeField] private Transform _target;
    [SerializeField] private CameraFollowConfig _config;

    private Vector3 _velocity;

    private void LateUpdate()
    {
        if (_target == null)
        {
            return;
        }

        var desired = _target.position + _target.rotation * _config.Offset;
        transform.position = Vector3.SmoothDamp(
            transform.position,
            desired,
            ref _velocity,
            _config.PositionSmoothTime,
            _config.MaxDistance,
            Time.deltaTime);

        var lookPoint = _target.position + Vector3.up * _config.LookAtHeight;
        transform.rotation = Quaternion.LookRotation(lookPoint - transform.position, Vector3.up);
    }
}

摄像机更新放在 LateUpdate,避免角色先后更新引起抖动。

输入与镜头协同细节

  1. 移动方向始终基于镜头平面投影,保证“W 是朝屏幕前方走”。
  2. 角色朝向与移动方向一致,保持控制直觉。
  3. 冲刺仅提升速度,不改变输入方向逻辑。
  4. 镜头 offset 跟随角色旋转,保持空间感一致。

与后续系统的对接预留

  1. 命令系统:输入采样后可转为 MoveCommand,支持回放。
  2. 状态机:将 Idle/Move/Sprint/Hit/Dead 状态接入移动开关。
  3. 技能系统:施法前摇时可短暂锁移动。
  4. Buff 系统:减速/加速通过修改 MotorConfig 或速度系数接入。

WebGL 兼容注意点

  1. 不使用复杂线程计算输入。
  2. 尽量减少每帧临时分配(本章所有路径无 new)。
  3. 鼠标锁定与浏览器焦点切换需要单独处理(后续章节补)。
  4. 移动和镜头逻辑解耦,便于接移动端触控。

验收清单

  1. 角色可稳定八方向移动,对角线速度正常。
  2. 切换冲刺时速度变化平滑,不突变。
  3. 镜头跟随无明显抖动与穿地。
  4. 30 FPS 与 120 FPS 下角色位移误差在可接受范围。

常见坑

坑 1:直接用 transform.position += ...

会绕开碰撞体系,后续穿墙与地形问题难收敛。优先用 CharacterController.Move

坑 2:移动和旋转都绑在世界坐标

镜头转向后控制方向会错乱。应基于镜头前向与右向计算。

坑 3:镜头直接追目标,不做平滑

小位移会放大成视觉抖动。必须做位置阻尼与统一更新时机。

本月作业

实现“冲刺体力条”并与移动系统联动:

  1. 冲刺每秒消耗体力,停止冲刺后缓慢恢复。
  2. 体力不足自动退出冲刺。
  3. UI 展示体力变化,并保持与移动状态一致。

下一章进入 Unity 入门实战 03:交互系统与拾取逻辑(基于射线 + 命令驱动)。