3. 角色移动
角色移动
移动是割草游戏的灵魂
在割草游戏中,你唯一能做的就是移动。没有攻击键,没有跳跃键,没有翻滚键。你的角色就像一个在僵尸潮中奔跑的马拉松选手——跑得快、跑得聪明,就能活得久。
这一章我们要实现一个流畅的角色移动系统,包括:在 XZ 平面上移动、摄像机平滑跟随、角色面向移动方向、以及碰撞检测。
理解 CharacterBody3D
Godot 4 提供了几种用于角色控制的节点类型,对于我们的割草游戏,CharacterBody3D 是最佳选择。
三种物理体对比
| 节点类型 | 特点 | 适用场景 |
|---|---|---|
| RigidBody3D | 受物理引擎完全控制,像真实物体 | 弹球、布娃娃、掉落的箱子 |
| StaticBody3D | 完全不动,像一堵墙 | 墙壁、地面、障碍物 |
| CharacterBody3D | 由脚本控制移动,但能检测碰撞 | 玩家角色、敌人 |
为什么选 CharacterBody3D?
CharacterBody3D 就像一个"有意识的人":你告诉它往哪走,它就往哪走。但如果前面有墙,它会停下来(不会穿过去)。这正好符合我们对玩家角色的需求。
生活化比喻:RigidBody3D 就像一个被扔出去的篮球(你扔完就不管了,物理引擎决定它怎么飞),CharacterBody3D 就像一个遥控小车(你用遥控器控制它,但它不会穿过墙壁)。
创建玩家场景
场景结构
Player (CharacterBody3D)
├── CollisionShape3D ← 碰撞体(圆柱形,半径0.3,高度1.0)
├── Model (Node3D) ← 3D模型容器
│ ├── Body (MeshInstance3D) ← 身体模型
│ └── Hat (MeshInstance3D) ← 帽子装饰
├── HealthComponent ← 生命值组件
└── WeaponManager ← 武器管理组件创建步骤
- 在 Godot 中创建新场景,根节点选择 CharacterBody3D,命名为
Player - 添加子节点 CollisionShape3D,在 Inspector 中设置:
- Shape:新建 CylinderShape3D
- Radius:0.3(角色身体的粗细)
- Height:1.0(角色身体的高度)
- 添加子节点 Node3D,命名为
Model(后续放入3D模型)
为什么用圆柱形碰撞体?
在俯视角游戏中,角色看起来是圆形的。圆柱形的碰撞体从上方看是一个圆,完美匹配俯视角的视觉。如果用方形碰撞体,角色在对角线方向移动时会"卡角",体验很差。
基础移动脚本
让我们写第一个版本的玩家移动脚本。它需要做三件事:
- 读取输入方向(WASD 或方向键)
- 在 XZ 平面上移动(不需要上下移动)
- 和墙壁/障碍物碰撞检测
// PlayerController.cs
// 挂载到 Player (CharacterBody3D) 节点上
using Godot;
public partial class PlayerController : CharacterBody3D
{
// === 可调参数 ===
// 移动速度(每秒移动多少个单位)
[Export] public float MoveSpeed = 5.0f;
// 加速度(从静止到满速需要多久)
[Export] public float Acceleration = 10.0f;
// 减速度(松开按键后多久停下来)
[Export] public float Deceleration = 8.0f;
// 当前移动速度(内部使用)
private Vector3 _currentVelocity = Vector3.Zero;
public override void _PhysicsProcess(double delta)
{
// 第一步:获取输入方向
Vector2 inputDirection = GetInputDirection();
// 第二步:将2D输入转换为3D的XZ平面移动
Vector3 targetVelocity = Vector3.Zero;
if (inputDirection.LengthSquared() > 0.01f)
{
targetVelocity = new Vector3(
inputDirection.X, // 左右 -> X轴
0, // 不需要上下移动
inputDirection.Y // 上下 -> Z轴
);
targetVelocity = targetVelocity.Normalized() * MoveSpeed;
// 让角色面向移动方向
UpdateRotation(targetVelocity);
}
// 第三步:平滑加减速
float accel = inputDirection.LengthSquared() > 0.01f
? Acceleration
: Deceleration;
_currentVelocity = _currentVelocity.Lerp(
targetVelocity,
(float)(accel * delta)
);
// 第四步:应用移动(带碰撞检测)
Velocity = _currentVelocity;
MoveAndSlide();
}
/// <summary>
/// 读取WASD或方向键输入,返回归一化的2D方向向量
/// </summary>
private Vector2 GetInputDirection()
{
Vector2 input = Vector2.Zero;
if (Input.IsActionPressed("move_up")) input.Y -= 1;
if (Input.IsActionPressed("move_down")) input.Y += 1;
if (Input.IsActionPressed("move_left")) input.X -= 1;
if (Input.IsActionPressed("move_right")) input.X += 1;
// 归一化:防止对角线移动速度更快
return input.LengthSquared() > 0.01f
? input.Normalized()
: Vector2.Zero;
}
/// <summary>
/// 让角色面向移动方向
/// </summary>
private void UpdateRotation(Vector3 moveDirection)
{
if (moveDirection.LengthSquared() < 0.01f) return;
// 计算目标旋转角度
float targetAngle = Mathf.Atan2(
moveDirection.X,
moveDirection.Z
);
// 平滑旋转到目标角度
float currentAngle = Rotation.Y;
float newAngle = Mathf.LerpAngle(
currentAngle,
targetAngle,
0.2f // 旋转速度
);
Rotation = new Vector3(0, newAngle, 0);
}
}# player_controller.gd
# 挂载到 Player (CharacterBody3D) 节点上
extends CharacterBody3D
# === 可调参数 ===
# 移动速度(每秒移动多少个单位)
@export var move_speed: float = 5.0
# 加速度(从静止到满速需要多久)
@export var acceleration: float = 10.0
# 减速度(松开按键后多久停下来)
@export var deceleration: float = 8.0
# 当前移动速度(内部使用)
var _current_velocity: Vector3 = Vector3.ZERO
func _physics_process(delta):
# 第一步:获取输入方向
var input_direction = get_input_direction()
# 第二步:将2D输入转换为3D的XZ平面移动
var target_velocity = Vector3.ZERO
if input_direction.length_squared() > 0.01:
target_velocity = Vector3(
input_direction.x, # 左右 -> X轴
0, # 不需要上下移动
input_direction.y # 上下 -> Z轴
)
target_velocity = target_velocity.normalized() * move_speed
# 让角色面向移动方向
update_rotation(target_velocity)
# 第三步:平滑加减速
var accel = acceleration if input_direction.length_squared() > 0.01 else deceleration
_current_velocity = _current_velocity.lerp(target_velocity, accel * delta)
# 第四步:应用移动(带碰撞检测)
velocity = _current_velocity
move_and_slide()
## 读取WASD或方向键输入,返回归一化的2D方向向量
func get_input_direction() -> Vector2:
var input = Vector2.ZERO
if Input.is_action_pressed("move_up"): input.y -= 1
if Input.is_action_pressed("move_down"): input.y += 1
if Input.is_action_pressed("move_left"): input.x -= 1
if Input.is_action_pressed("move_right"): input.x += 1
# 归一化:防止对角线移动速度更快
return input.normalized() if input.length_squared() > 0.01 else Vector2.ZERO
## 让角色面向移动方向
func update_rotation(move_direction: Vector3):
if move_direction.length_squared() < 0.01:
return
# 计算目标旋转角度
var target_angle = atan2(move_direction.x, move_direction.z)
# 平滑旋转到目标角度
var current_angle = rotation.y
var new_angle = lerp_angle(current_angle, target_angle, 0.2)
rotation = Vector3(0, new_angle, 0)代码逐行解释
| 关键代码 | 作用 | 为什么要这样做 |
|---|---|---|
input.Normalized() | 把方向向量长度变成1 | 防止对角线移动速度是直线移动的1.414倍 |
_currentVelocity.Lerp() | 平滑过渡速度 | 角色不会"瞬移",而是像真人一样有惯性 |
MoveAndSlide() | 带碰撞检测的移动 | 碰到墙壁会停住,不会穿过去 |
Mathf.Atan2(X, Z) | 计算朝向角度 | 根据移动方向算出角色应该面向哪里 |
Mathf.LerpAngle() | 平滑旋转角度 | 角色转向时不会瞬间旋转,更自然 |
注意 MoveAndSlide
MoveAndSlide() 是 Godot 中处理角色移动的核心方法。它会自动处理碰撞响应——如果角色撞到墙壁,它会被"推"到墙壁旁边而不是穿过去。不要用 Position += velocity * delta 替代它,否则角色会穿墙。
摄像机平滑跟随
摄像机需要始终以玩家为中心,并且平滑地跟随玩家移动。我们在上一章已经写了一个基础的摄像机跟随脚本,现在来完善它。
为什么需要平滑跟随?
如果摄像机瞬间跳到玩家位置,画面会"抖动",看着头晕。就像你坐在一辆颠簸的公交车上——每一下颠簸你的视线都跟着跳。但如果坐在有减震器的车上,视线就平稳多了。Lerp 就是摄像机的减震器。
增强版摄像机跟随
// EnhancedCameraFollow.cs
// 增强版摄像机跟随,支持边界限制和屏幕震动
using Godot;
public partial class EnhancedCameraFollow : Camera3D
{
[Export] public NodePath TargetPath;
private Node3D _target;
// 跟随参数
[Export] public float FollowSmoothSpeed = 5.0f;
[Export] public Vector3 Offset = new Vector3(0, 15, 0);
// 视角限制参数
[Export] public bool EnableBounds = false;
[Export] public float BoundSize = 50f;
// 屏幕震动参数
private float _shakeIntensity = 0f;
private float _shakeDuration = 0f;
public override void _Ready()
{
if (TargetPath != null)
_target = GetNode<Node3D>(TargetPath);
}
public override void _PhysicsProcess(double delta)
{
if (_target == null) return;
// 计算目标位置
Vector3 targetPos = _target.GlobalPosition + Offset;
// 边界限制
if (EnableBounds)
{
targetPos = new Vector3(
Mathf.Clamp(targetPos.X, -BoundSize, BoundSize),
targetPos.Y,
Mathf.Clamp(targetPos.Z, -BoundSize, BoundSize)
);
}
// 平滑跟随
GlobalPosition = GlobalPosition.Lerp(
targetPos,
(float)(FollowSmoothSpeed * delta)
);
// 处理屏幕震动
UpdateShake((float)delta);
}
/// <summary>
/// 触发屏幕震动(受到伤害时调用)
/// </summary>
public void Shake(float intensity, float duration)
{
_shakeIntensity = intensity;
_shakeDuration = duration;
}
private void UpdateShake(float delta)
{
if (_shakeDuration > 0)
{
_shakeDuration -= delta;
// 在当前位置附近随机偏移
Vector3 shakeOffset = new Vector3(
GD.Randf() * _shakeIntensity * 2 - _shakeIntensity,
0,
GD.Randf() * _shakeIntensity * 2 - _shakeIntensity
);
Position += shakeOffset;
}
else
{
_shakeIntensity = 0;
}
}
}# enhanced_camera_follow.gd
# 增强版摄像机跟随,支持边界限制和屏幕震动
extends Camera3D
@export var target_path: NodePath
var _target: Node3D
# 跟随参数
@export var follow_smooth_speed: float = 5.0
@export var offset: Vector3 = Vector3(0, 15, 0)
# 视角限制参数
@export var enable_bounds: bool = false
@export var bound_size: float = 50.0
# 屏幕震动参数
var _shake_intensity: float = 0.0
var _shake_duration: float = 0.0
func _ready():
if target_path:
_target = get_node(target_path)
func _physics_process(delta):
if _target == null:
return
# 计算目标位置
var target_pos = _target.global_position + offset
# 边界限制
if enable_bounds:
target_pos = Vector3(
clampf(target_pos.x, -bound_size, bound_size),
target_pos.y,
clampf(target_pos.z, -bound_size, bound_size)
)
# 平滑跟随
global_position = global_position.lerp(
target_pos,
follow_smooth_speed * delta
)
# 处理屏幕震动
update_shake(delta)
## 触发屏幕震动(受到伤害时调用)
func shake(intensity: float, duration: float):
_shake_intensity = intensity
_shake_duration = duration
func update_shake(delta):
if _shake_duration > 0:
_shake_duration -= delta
# 在当前位置附近随机偏移
var shake_offset = Vector3(
randf() * _shake_intensity * 2 - _shake_intensity,
0,
randf() * _shake_intensity * 2 - _shake_intensity
)
position += shake_offset
else:
_shake_intensity = 0.0碰撞检测——角色不能穿墙
在割草游戏中,角色需要和地图上的障碍物(树、石头、墙壁)发生碰撞。Godot 的 CharacterBody3D 已经内置了碰撞检测功能,我们只需要:
- 给玩家添加 CollisionShape3D(已在上面的场景结构中完成)
- 给障碍物添加 StaticBody3D + CollisionShape3D
- 调用
MoveAndSlide()即可自动处理碰撞
碰撞层设置
Godot 使用"碰撞层"系统来决定哪些物体之间会发生碰撞:
| 层编号 | 名称 | 包含的对象 |
|---|---|---|
| 第1层 | player | 玩家角色 |
| 第2层 | enemies | 所有敌人 |
| 第3层 | environment | 地面、墙壁、障碍物 |
| 第4层 | projectiles | 弹幕、飞行物 |
| 第5层 | items | 掉落物(经验宝石等) |
碰撞层和掩码
- Collision Layer:我属于哪一层(别人通过这个找到我)
- Collision Mask:我能碰到哪些层(我关心哪些东西)
比如玩家的 Mask 设置为第3层(environment),就意味着玩家只能和墙壁碰撞,不会和弹幕碰撞(弹幕打到玩家是通过 Area3D 的信号检测的,不是物理碰撞)。
创建简单障碍物
// Obstacle.cs
// 简单的障碍物脚本,挂载到石头/树等障碍物上
using Godot;
public partial class Obstacle : StaticBody3D
{
// 障碍物类型
[Export] public string ObstacleType = "rock";
public override void _Ready()
{
// 确保设置了碰撞层
CollisionLayer = 1 << 2; // 第3层:environment
CollisionMask = 0; // 不检测任何碰撞(静态物体不需要)
}
}# obstacle.gd
# 简单的障碍物脚本,挂载到石头/树等障碍物上
extends StaticBody3D
# 障碍物类型
@export var obstacle_type: String = "rock"
func _ready():
# 确保设置了碰撞层
collision_layer = 1 << 2 # 第3层:environment
collision_mask = 0 # 不检测任何碰撞(静态物体不需要)移动手感调优
移动手感是游戏体验的核心。以下是几个关键参数的调优建议:
参数对照表
| 参数 | 太小 | 太大 | 推荐值 |
|---|---|---|---|
| MoveSpeed | 角色像蜗牛,着急 | 角色像闪电,控制不住 | 4.0 ~ 6.0 |
| Acceleration | 角色反应迟钝 | 角色瞬间起步,像机器人 | 8.0 ~ 15.0 |
| Deceleration | 松手后滑行太远 | 松手后瞬间停下 | 6.0 ~ 12.0 |
| FollowSmoothSpeed | 摄像机追不上,画面滞后 | 摄像机抖动,看着晕 | 4.0 ~ 8.0 |
调参建议
先设置较大的加速度和减速度(比如都是 20),让角色响应灵敏。然后慢慢降低,直到感觉"有点惯性但不过分"为止。移动手感需要反复测试才能找到最佳值。
完整场景配置清单
创建完玩家场景后,检查以下配置:
| 检查项 | 位置 | 推荐值 |
|---|---|---|
| 根节点类型 | Player 根节点 | CharacterBody3D |
| 碰撞形状 | CollisionShape3D | CylinderShape3D,半径0.3,高度1.0 |
| 碰撞层 | Collision Layer | 第1层(player) |
| 碰撞掩码 | Collision Mask | 第3层(environment) |
| 移动脚本 | Player 根节点 | PlayerController.cs / .gd |
| 脚本速度参数 | Inspector -> Move Speed | 5.0 |
| 模型容器 | Model 节点 | Node3D(后续放3D模型) |
总结
本章我们实现了:
- 玩家基础移动 — 使用 CharacterBody3D 在 XZ 平面上移动
- 平滑加减速 — 角色不会瞬间启动或停止,有真实的惯性
- 角色朝向 — 自动面向移动方向
- 摄像机平滑跟随 — 使用 Lerp 实现减震效果
- 屏幕震动 — 受伤时的视觉反馈
- 碰撞检测 — 角色不能穿过墙壁和障碍物
现在你的角色可以在地图上自由移动了!下一步,我们要让角色拥有自动攻击的能力。
