3. 载具控制
载具控制
上一章我们搭好了项目和摄像机,这一章来让吉普车动起来。赤色要塞的核心体验就是"开车",所以载具控制是整个游戏最重要的基础。
选择载具节点类型
在 Godot 4 中,有三种3D物理节点可以用来做载具:
| 节点类型 | 特点 | 适合场景 |
|---|---|---|
| CharacterBody3D | 自带 MoveAndSlide(),手动控制移动 | 绝大多数角色和载具(推荐) |
| RigidBody3D | 受物理引擎控制,有质量和惯性 | 需要真实物理效果(如赛车) |
| VehicleBody3D | 专门为汽车设计的节点 | 需要真实轮胎物理的赛车游戏 |
为什么选 CharacterBody3D?
赤色要塞的操控不需要真实的物理引擎。我们需要的是"按方向键车就往那个方向走"的简单直接的操控感。CharacterBody3D 最适合这种"你想让它怎么动它就怎么动"的场景。
如果用 RigidBody3D,你得和物理引擎"搏斗"——给力、给扭矩、调摩擦力,才能让车按你想要的方式移动。太复杂了,而且操控感可能还不好。
吉普车场景结构
创建一个新场景 scenes/player/Jeep.tscn:
Jeep (CharacterBody3D) ← 根节点:物理角色体
├── MeshInstance3D ← 临时用一个 BoxMesh 代替吉普车模型
├── CollisionShape3D ← 碰撞形状(BoxShape3D)
├── RayCast3D ← 地面检测射线(跟随地形高度用)
└── GunMount (Node3D) ← 武器挂点(后续用于射击)
└── MeshInstance3D ← 炮管/枪的临时模型节点参数设置
| 节点 | 属性 | 值 | 说明 |
|---|---|---|---|
| Jeep (CharacterBody3D) | Motion Mode | Floating | 俯视角不需要重力 |
| MeshInstance3D | Mesh | BoxMesh 2x1x3 | 临时吉普车(长方形) |
| CollisionShape3D | Shape | BoxShape3D 2x1x3 | 碰撞盒和模型大小一致 |
| RayCast3D | Target Position | (0, -2, 0) | 向下发射射线检测地面 |
Motion Mode 设为 Floating
CharacterBody3D 默认是"地面模式"(会检测是否站在地面上)。我们是俯视角游戏,不需要重力,所以切换为 Floating 模式。这样角色就像悬浮在地面上一样,不会因为"没有踩到地面"而坠落。
8方向移动实现
8方向移动就是玩家可以按上下左右以及四个对角线方向移动。关键在于读取输入后归一化(normalize),让对角线移动速度不会比单方向快。
using Godot;
/// <summary>
/// 吉普车控制器 - 8方向移动 + 自动旋转朝向
/// </summary>
public partial class Jeep : CharacterBody3D
{
[Export] public float MoveSpeed { get; set; } = 8.0f; // 最大移动速度
[Export] public float Acceleration { get; set; } = 15.0f; // 加速度
[Export] public float Friction { get; set; } = 10.0f; // 减速摩擦力
[Export] public float RotationSpeed { get; set; } = 10.0f; // 旋转速度
public override void _PhysicsProcess(double delta)
{
// 1. 读取输入方向
Vector2 inputDir = Vector2.Zero;
inputDir.X = Input.GetAxis("move_left", "move_right"); // A/D 或 左/右
inputDir.Y = Input.GetAxis("move_up", "move_down"); // W/S 或 上/下
// 2. 将2D输入转换为3D移动方向(XZ平面)
// inputDir.X → 3D的X轴(左右)
// inputDir.Y → 3D的Z轴(上下在屏幕上对应前后)
Vector3 moveDirection = new Vector3(inputDir.X, 0, inputDir.Y).Normalized();
// 3. 根据是否有输入,决定加速还是减速
if (moveDirection != Vector3.Zero)
{
// 加速:朝着输入方向移动
Velocity = Velocity.MoveToward(
moveDirection * MoveSpeed,
(float)(Acceleration * delta)
);
// 旋转:让车头朝向移动方向
RotateToward(moveDirection, (float)(RotationSpeed * delta));
}
else
{
// 减速:没有输入时慢慢停下来
Velocity = Velocity.MoveToward(
Vector3.Zero,
(float)(Friction * delta)
);
}
// 4. 执行移动
MoveAndSlide();
}
/// <summary>
/// 平滑旋转朝向目标方向
/// </summary>
private void RotateToward(Vector3 direction, float speed)
{
// 计算目标角度(使用atan2将方向向量转为角度)
float targetAngle = Mathf.Atan2(direction.X, direction.Z);
float currentAngle = Rotation.Y;
// 使用LerpAngle平滑插值(它会自动选择最短的旋转路径)
float newAngle = Mathf.LerpAngle(currentAngle, targetAngle, speed);
// 只更新Y轴旋转
Rotation = new Vector3(0, newAngle, 0);
}
}# 吉普车控制器 - 8方向移动 + 自动旋转朝向
extends CharacterBody3D
@export var move_speed: float = 8.0 # 最大移动速度
@export var acceleration: float = 15.0 # 加速度
@export var friction: float = 10.0 # 减速摩擦力
@export var rotation_speed: float = 10.0 # 旋转速度
func _physics_process(delta):
# 1. 读取输入方向
var input_dir = Vector2.ZERO
input_dir.x = Input.get_axis("move_left", "move_right") # A/D 或 左/右
input_dir.y = Input.get_axis("move_up", "move_down") # W/S 或 上/下
# 2. 将2D输入转换为3D移动方向(XZ平面)
# input_dir.x → 3D的X轴(左右)
# input_dir.y → 3D的Z轴(上下在屏幕上对应前后)
var move_direction = Vector3(input_dir.x, 0, input_dir.y).normalized()
# 3. 根据是否有输入,决定加速还是减速
if move_direction != Vector3.ZERO:
# 加速:朝着输入方向移动
velocity = velocity.move_toward(
move_direction * move_speed,
acceleration * delta
)
# 旋转:让车头朝向移动方向
_rotate_toward(move_direction, rotation_speed * delta)
else:
# 减速:没有输入时慢慢停下来
velocity = velocity.move_toward(
Vector3.ZERO,
friction * delta
)
# 4. 执行移动
move_and_slide()
## 平滑旋转朝向目标方向
func _rotate_toward(direction: Vector3, speed: float):
# 计算目标角度(使用atan2将方向向量转为角度)
var target_angle = atan2(direction.x, direction.z)
var current_angle = rotation.y
# 使用lerp_angle平滑插值(它会自动选择最短的旋转路径)
var new_angle = lerp_angle(current_angle, target_angle, speed)
# 只更新Y轴旋转
rotation.y = new_angle关键代码解释
| 代码 | 作用 | 为什么需要 |
|---|---|---|
Input.GetAxis("left", "right") | 获取-1到1之间的方向值 | 按左得-1,按右得1,都不按得0 |
.Normalized() | 把向量长度缩为1 | 防止对角线移动速度是单方向的1.41倍 |
MoveToward() | 从当前值朝目标值移动 | 实现加速和减速效果 |
Mathf.Atan2(x, z) | 将方向向量转为角度 | 知道该朝哪个方向旋转 |
Mathf.LerpAngle() | 角度平滑插值 | 车辆慢慢转向,不是瞬间掉头 |
速度和操控参数调优
不同参数组合会带来完全不同的操控感:
| 参数风格 | MoveSpeed | Acceleration | Friction | 操控感 |
|---|---|---|---|---|
| 轻快灵活 | 12.0 | 25.0 | 20.0 | 像开遥控车,起步快刹车快 |
| 均衡推荐 | 8.0 | 15.0 | 10.0 | 有一定惯性,但不失控 |
| 重型沉稳 | 5.0 | 8.0 | 5.0 | 像开真正的坦克,惯性大 |
怎么调出好的操控感?
没有标准答案,只有"玩起来爽不爽"。 建议从推荐值开始,反复试玩,根据感觉微调。一个好方法是:
- 先设一个较高的速度(12)
- 跑起来后松手,看滑行距离是否自然
- 如果觉得"刹不住车",增大 Friction
- 如果觉得"太灵了",减小 Acceleration
2.5D 中的移动平面
在2.5D俯视角游戏中,角色在 XZ 平面上移动。这一点非常重要:
Y(高度/上方)
↑
|
|
+------→ X(右方向)
/
/
↓
Z(前方/下方,在屏幕上对应"向下")| 2D屏幕方向 | 对应3D方向 | 说明 |
|---|---|---|
| 左 | -X | 屏幕左边 |
| 右 | +X | 屏幕右边 |
| 上 | -Z | 屏幕上方(3D中Z轴正方向在摄像机角度下映射为屏幕上方) |
| 下 | +Z | 屏幕下方 |
注意输入映射
在我们的设置中,move_up 对应 W 键,在3D空间中应该让角色向 -Z 方向移动。代码中 inputDir.Y = Input.GetAxis("move_up", "move_down") 意味着按 W 得到 -1,按 S 得到 +1,正好对应 -Z 和 +Z 方向。
地形高度跟随
如果地图上有高低起伏的地形(这是2.5D的优势!),吉普车需要跟随地面的高度变化。我们用 RayCast3D 来实现:
RayCast 射线检测原理
🚗 吉普车(假设 Y=5)
│
│ RayCast 向下发射
↓
▓▓▓▓▓▓▓ 地面(可能 Y=0 或 Y=2 等不同高度)射线从吉普车位置向下发射,碰到地面后返回碰撞点的 Y 坐标。吉普车就移动到这个高度。
using Godot;
/// <summary>
/// 地形高度跟随组件
/// 挂在吉普车上,让车辆跟随3D地形的高度变化
/// </summary>
public partial class TerrainFollower : Node3D
{
[Export] public float GroundOffset { get; set; } = 0.5f; // 车底离地面的距离
[Export] public float SnapSpeed { get; set; } = 15.0f; // 贴地速度
private CharacterBody3D _parent;
private RayCast3D _groundRay;
public override void _Ready()
{
_parent = GetParent<CharacterBody3D>();
_groundRay = _parent.GetNode<RayCast3D>("RayCast3D");
}
public override void _PhysicsProcess(double delta)
{
if (_groundRay == null || !_groundRay.IsColliding()) return;
// 获取射线碰撞点(地面的高度)
Vector3 collisionPoint = _groundRay.GetCollisionPoint();
float targetY = collisionPoint.Y + GroundOffset;
// 平滑过渡到目标高度
float currentY = _parent.Position.Y;
float newY = Mathf.MoveToward(currentY, targetY, (float)(SnapSpeed * (float)delta));
// 更新Y坐标,保持XZ不变
_parent.Position = new Vector3(_parent.Position.X, newY, _parent.Position.Z);
}
}# 地形高度跟随组件
# 挂在吉普车上,让车辆跟随3D地形的高度变化
extends Node3D
@export var ground_offset: float = 0.5 # 车底离地面的距离
@export var snap_speed: float = 15.0 # 贴地速度
var _parent: CharacterBody3D
var _ground_ray: RayCast3D
func _ready():
_parent = get_parent() as CharacterBody3D
_ground_ray = _parent.get_node("RayCast3D")
func _physics_process(delta):
if _ground_ray == null or not _ground_ray.is_colliding():
return
# 获取射线碰撞点(地面的高度)
var collision_point = _ground_ray.get_collision_point()
var target_y = collision_point.y + ground_offset
# 平滑过渡到目标高度
var current_y = _parent.position.y
var new_y = move_toward(current_y, target_y, snap_speed * delta)
# 更新Y坐标,保持XZ不变
_parent.position.y = new_yRayCast3D 配置
| 属性 | 值 | 说明 |
|---|---|---|
| Enabled | true | 必须启用 |
| Target Position | (0, -5, 0) | 向下发射5个单位 |
| Collision Mask | 只选地面层 | 避免被其他物体干扰 |
完整的吉普车脚本
把移动控制和地形跟随整合在一起:
using Godot;
/// <summary>
/// 吉普车完整控制器
/// 包含:8方向移动、自动旋转、地形高度跟随
/// </summary>
public partial class Jeep : CharacterBody3D
{
// 移动参数
[ExportGroup("Movement")]
[Export] public float MoveSpeed { get; set; } = 8.0f;
[Export] public float Acceleration { get; set; } = 15.0f;
[Export] public float Friction { get; set; } = 10.0f;
[Export] public float RotationSpeed { get; set; } = 10.0f;
// 地形跟随参数
[ExportGroup("Terrain")]
[Export] public float GroundOffset { get; set; } = 0.5f;
[Export] public float SnapSpeed { get; set; } = 15.0f;
private RayCast3D _groundRay;
public override void _Ready()
{
// 获取地面检测射线
_groundRay = GetNode<RayCast3D>("RayCast3D");
}
public override void _PhysicsProcess(double delta)
{
HandleMovement((float)delta);
HandleTerrainFollowing((float)delta);
}
/// <summary>
/// 处理8方向移动和旋转
/// </summary>
private void HandleMovement(float delta)
{
// 读取输入
Vector2 inputDir = Vector2.Zero;
inputDir.X = Input.GetAxis("move_left", "move_right");
inputDir.Y = Input.GetAxis("move_up", "move_down");
Vector3 moveDirection = new Vector3(inputDir.X, 0, inputDir.Y).Normalized();
if (moveDirection != Vector3.Zero)
{
// 加速
Velocity = Velocity.MoveToward(
moveDirection * MoveSpeed,
Acceleration * delta
);
// 旋转朝向移动方向
float targetAngle = Mathf.Atan2(moveDirection.X, moveDirection.Z);
Rotation = new Vector3(0, Mathf.LerpAngle(Rotation.Y, targetAngle, RotationSpeed * delta), 0);
}
else
{
// 减速
Velocity = Velocity.MoveToward(Vector3.Zero, Friction * delta);
}
// 保持Y轴速度为0(不受重力影响)
Velocity = new Vector3(Velocity.X, 0, Velocity.Z);
MoveAndSlide();
}
/// <summary>
/// 跟随地形高度
/// </summary>
private void HandleTerrainFollowing(float delta)
{
if (_groundRay == null || !_groundRay.IsColliding()) return;
Vector3 collisionPoint = _groundRay.GetCollisionPoint();
float targetY = collisionPoint.Y + GroundOffset;
float newY = Mathf.MoveToward(GlobalPosition.Y, targetY, SnapSpeed * delta);
GlobalPosition = new Vector3(GlobalPosition.X, newY, GlobalPosition.Z);
}
}# 吉普车完整控制器
# 包含:8方向移动、自动旋转、地形高度跟随
extends CharacterBody3D
# 移动参数
@export_group("Movement")
@export var move_speed: float = 8.0
@export var acceleration: float = 15.0
@export var friction: float = 10.0
@export var rotation_speed: float = 10.0
# 地形跟随参数
@export_group("Terrain")
@export var ground_offset: float = 0.5
@export var snap_speed: float = 15.0
var _ground_ray: RayCast3D
func _ready():
# 获取地面检测射线
_ground_ray = $RayCast3D
func _physics_process(delta):
_handle_movement(delta)
_handle_terrain_following(delta)
## 处理8方向移动和旋转
func _handle_movement(delta: float):
# 读取输入
var input_dir = Vector2.ZERO
input_dir.x = Input.get_axis("move_left", "move_right")
input_dir.y = Input.get_axis("move_up", "move_down")
var move_direction = Vector3(input_dir.x, 0, input_dir.y).normalized()
if move_direction != Vector3.ZERO:
# 加速
velocity = velocity.move_toward(
move_direction * move_speed,
acceleration * delta
)
# 旋转朝向移动方向
var target_angle = atan2(move_direction.x, move_direction.z)
rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta)
else:
# 减速
velocity = velocity.move_toward(Vector3.ZERO, friction * delta)
# 保持Y轴速度为0(不受重力影响)
velocity.y = 0
move_and_slide()
## 跟随地形高度
func _handle_terrain_following(delta: float):
if _ground_ray == null or not _ground_ray.is_colliding():
return
var collision_point = _ground_ray.get_collision_point()
var target_y = collision_point.y + ground_offset
var new_y = move_toward(global_position.y, target_y, snap_speed * delta)
global_position.y = new_y用占位模型测试
现在可以用简单的形状来测试移动效果:
| 物体 | 使用的临时形状 | 颜色 |
|---|---|---|
| 吉普车车身 | BoxMesh (2 x 0.8 x 3) | 军绿色 |
| 吉普车炮塔 | BoxMesh (1 x 0.5 x 1.5) | 深绿色 |
| 车轮(4个) | CylinderMesh (0.4 x 0.3) | 黑色 |
为什么要用占位形状?
因为找/做3D模型很花时间。先用简单的几何形状把游戏逻辑跑通,确认操控感没问题后,再替换成真正的3D模型。这叫"灰色方块开发法"(Greyboxing),是游戏行业的标准做法。
碰撞层设置
为了让不同类型的物体之间正确碰撞,我们需要设置碰撞层(Collision Layer):
| 物体类型 | Layer(自己在哪层) | Mask(检测哪些层) | 层号 |
|---|---|---|---|
| 玩家(吉普车) | 1 | 2, 3, 4 | 第1层 |
| 敌人 | 2 | 1, 5 | 第2层 |
| 玩家子弹 | 3 | 2, 4 | 第3层 |
| 敌人子弹 | 4 | 1 | 第4层 |
| 建筑物/地形 | 5 | - | 第5层 |
碰撞层的通俗解释
把碰撞层想象成"频道":
- Layer:你自己在哪个频道广播(别人能听到你)
- Mask:你在听哪些频道(你能听到谁)
比如玩家在1频道广播,敌人听1频道,所以敌人能检测到玩家。子弹在3频道广播,敌人听3频道,所以子弹能打中敌人。但玩家不听3频道,所以子弹不会打中自己。
本章检查清单
下一章
吉普车能动起来了,下一步是给它装上武器!
