路线阶段:Unity 入门实战第 2 章。
本章目标:实现“能玩且能扩展”的角色控制核心,而不是一次性 demo 控制器。
学习目标
完成本章后,你应该能做到:
- 将输入采样、移动逻辑、角色朝向拆分成独立模块。
- 实现帧率无关的移动与加速度控制。
- 搭建平滑摄像机跟随与旋转逻辑,避免眩晕和抖动。
- 保持输入层可替换(键鼠/手柄/触控)并兼容 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;
}
短期可跑,但会快速遇到问题:
- 输入、移动、镜头强耦合,后续难替换。
- 缺少加速/减速曲线,手感生硬。
- 镜头直接跟随导致抖动、穿模、眩晕。
- 无法支持锁定目标、冲刺、受击硬直等后续需求。
模块拆分方案
本章拆为四层:
PlayerInputSampler:输入采样与归一化。PlayerMotor:速度积分、碰撞移动、朝向旋转。PlayerController:将输入意图转换为移动命令。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,避免角色先后更新引起抖动。
输入与镜头协同细节
- 移动方向始终基于镜头平面投影,保证“W 是朝屏幕前方走”。
- 角色朝向与移动方向一致,保持控制直觉。
- 冲刺仅提升速度,不改变输入方向逻辑。
- 镜头 offset 跟随角色旋转,保持空间感一致。
与后续系统的对接预留
- 命令系统:输入采样后可转为
MoveCommand,支持回放。 - 状态机:将 Idle/Move/Sprint/Hit/Dead 状态接入移动开关。
- 技能系统:施法前摇时可短暂锁移动。
- Buff 系统:减速/加速通过修改
MotorConfig或速度系数接入。
WebGL 兼容注意点
- 不使用复杂线程计算输入。
- 尽量减少每帧临时分配(本章所有路径无 new)。
- 鼠标锁定与浏览器焦点切换需要单独处理(后续章节补)。
- 移动和镜头逻辑解耦,便于接移动端触控。
验收清单
- 角色可稳定八方向移动,对角线速度正常。
- 切换冲刺时速度变化平滑,不突变。
- 镜头跟随无明显抖动与穿地。
- 30 FPS 与 120 FPS 下角色位移误差在可接受范围。
常见坑
坑 1:直接用 transform.position += ...
会绕开碰撞体系,后续穿墙与地形问题难收敛。优先用 CharacterController.Move。
坑 2:移动和旋转都绑在世界坐标
镜头转向后控制方向会错乱。应基于镜头前向与右向计算。
坑 3:镜头直接追目标,不做平滑
小位移会放大成视觉抖动。必须做位置阻尼与统一更新时机。
本月作业
实现“冲刺体力条”并与移动系统联动:
- 冲刺每秒消耗体力,停止冲刺后缓慢恢复。
- 体力不足自动退出冲刺。
- UI 展示体力变化,并保持与移动状态一致。
下一章进入 Unity 入门实战 03:交互系统与拾取逻辑(基于射线 + 命令驱动)。