2. 项目搭建
项目搭建
上一章我们想清楚了游戏要做什么,现在该动手了。本章要把 Godot 项目建起来,配好物理引擎、摄像机和基础输入系统,为后面的开发打好地基。
本章你将学到
- 创建竞速游戏项目和目录结构
- 竞速游戏物理引擎的特殊配置
- 追踪/跟随摄像机系统的实现
- 分段计时系统的基础框架
- 输入映射的设置方法
创建项目
打开 Godot 4.6,新建一个项目,选择 Forward+ 渲染器(适合3D游戏)。项目名可以叫 MotoRally。
项目目录结构
良好的目录结构能让团队协作和后期维护轻松很多。推荐的结构如下:
在 Godot 的文件系统面板中,创建以下文件夹:
res://
├── scenes/
│ ├── main.tscn # 主场景入口
│ ├── motorcycle/
│ │ └── motorcycle.tscn # 摩托车场景
│ ├── tracks/
│ │ └── desert_track.tscn # 沙漠赛道
│ └── ui/
│ ├── hud.tscn # 游戏HUD
│ └── results.tscn # 成绩画面
├── scripts/
│ ├── motorcycle/
│ │ └── motorcycle_controller.gd
│ ├── camera/
│ │ └── chase_camera.gd
│ ├── track/
│ │ ├── stage_timer.gd
│ │ └── checkpoint.gd
│ └── ui/
│ └── speedometer.gd
├── assets/
│ ├── models/
│ ├── textures/
│ ├── audio/
│ └── particles/
└── data/
└── track_data/物理引擎配置
竞速游戏对物理精度有特殊要求。默认的 Godot 物理设置可能不够稳定,需要调整。
为什么竞速游戏的物理配置不一样?
普通游戏的物体移动速度不快,物理引擎每秒算60次就够了。但竞速游戏里的摩托车可能跑到 180km/h(约50m/s),如果物理步长太长,两个物理帧之间摩托车可能"跳过"一堵墙——这就是隧道效应(Tunneling)。
解决办法:提高物理帧率,让每一步的距离更短。
配置步骤
打开 项目 → 项目设置 → General → Physics:
| 设置项 | 推荐值 | 说明 |
|---|---|---|
| Physics Ticks per Second | 120 | 竞速游戏建议120,是默认60的两倍 |
| Physics Jitter Fix | 0.5 | 减少物理帧抖动 |
在 项目设置 → Application → Run 中设置主场景为 scenes/main.tscn。
输入映射设置
输入映射就是告诉 Godot:"当玩家按某个键时,对应什么动作"。这样代码里不用写死具体按键,换手柄或改键位都很方便。
打开 项目 → 项目设置 → Input Map,添加以下动作:
| 动作名 | 键盘按键 | 手柄按钮 |
|---|---|---|
accelerate | W / 上方向键 | 右扳机 RT |
brake_front | Space | 左扳机 LT |
brake_rear | S / 下方向键 | A按钮 |
steer_left | A / 左方向键 | 左摇杆 左 |
steer_right | D / 右方向键 | 左摇杆 右 |
lean_left | Q | 右摇杆 左 |
lean_right | E | 右摇杆 右 |
reset_position | R | Back/Select |
pause | Escape | Start |
关于刹车分成两个
摩托车有前刹和后刹,它们的用途不同。前刹制动力强(用于急刹),后刹比较温和(用于稳定减速)。在 摩托车物理 章节会详细讲解。
追踪摄像机系统
竞速游戏的摄像机需要跟在摩托车后面,让玩家能看到前方的路和自己的车。
摄像机设计要点
- 平滑跟随:摄像机不是死死钉在摩托车上,而是"懒洋洋地"追着走,这样画面才不晃
- 速度感配合:加速时摄像机稍微拉远,减速时靠近
- 转弯时偏移:转弯时摄像机稍微偏向弯道外侧,让玩家看到更多前方路况
摄像机节点结构
CameraRig (Node3D) ← 摄像机支架
├── SpringArm3D ← 弹簧臂(处理碰撞和距离)
│ └── Camera3D ← 摄像机本体完整摄像机代码
// 追踪摄像机:平滑跟随摩托车
public partial class ChaseCamera : Node3D
{
[Export] public NodePath TargetPath; // 跟踪目标(摩托车)
[Export] public float FollowSpeed = 5.0f; // 跟随速度
[Export] public float LookAheadSpeed = 3.0f; // 前瞻速度
[Export] public float CameraDistance = 6.0f; // 摄像机距离
[Export] public float CameraHeight = 2.5f; // 摄像机高度
[Export] public float SpeedZoomFactor = 0.03f; // 速度缩放系数
private Node3D _target;
private Vector3 _velocity;
public override void _Ready()
{
if (TargetPath != null)
_target = GetNode<Node3D>(TargetPath);
}
public override void _PhysicsProcess(double delta)
{
if (_target == null) return;
var dt = (float)delta;
// 计算目标位置:在摩托车的后方上方
Vector3 backDir = -_target.Basis.Z; // 摩托车后方
Vector3 upDir = Vector3.Up;
// 获取目标速度来调整距离
float speed = 0f;
if (_target is RigidBody3D rb)
speed = rb.LinearVelocity.Length();
float dynamicDistance = CameraDistance + speed * SpeedZoomFactor;
float dynamicHeight = CameraHeight + speed * 0.01f;
Vector3 targetPos = _target.GlobalPosition
+ backDir * dynamicDistance
+ upDir * dynamicHeight;
// 平滑移动到目标位置
GlobalPosition = GlobalPosition.Lerp(targetPos, FollowSpeed * dt);
// 看向摩托车前方稍远的位置(前瞻)
Vector3 lookTarget = _target.GlobalPosition + _target.Basis.Z * 3f;
var currentBasis = GlobalTransform.Basis;
var targetBasis = GlobalTransform.LookingAt(lookTarget, Vector3.Up).Basis;
// 平滑旋转
var newBasis = new Basis(
currentBasis.X.Lerp(targetBasis.X, LookAheadSpeed * dt),
currentBasis.Y.Lerp(targetBasis.Y, LookAheadSpeed * dt),
currentBasis.Z.Lerp(targetBasis.Z, LookAheadSpeed * dt)
);
GlobalTransform = new Transform3D(newBasis, GlobalPosition);
}
}# 追踪摄像机:平滑跟随摩托车
extends Node3D
@export var target_path: NodePath # 跟踪目标(摩托车)
@export var follow_speed: float = 5.0 # 跟随速度
@export var look_ahead_speed: float = 3.0 # 前瞻速度
@export var camera_distance: float = 6.0 # 摄像机距离
@export var camera_height: float = 2.5 # 摄像机高度
@export var speed_zoom_factor: float = 0.03 # 速度缩放系数
var _target: Node3D
func _ready():
if target_path:
_target = get_node(target_path)
func _physics_process(delta):
if _target == null:
return
# 计算目标位置:在摩托车的后方上方
var back_dir = -_target.basis.z # 摩托车后方
var up_dir = Vector3.UP
# 获取目标速度来调整距离
var speed = 0.0
if _target is RigidBody3D:
speed = _target.linear_velocity.length()
var dynamic_distance = camera_distance + speed * speed_zoom_factor
var dynamic_height = camera_height + speed * 0.01
var target_pos = _target.global_position \
+ back_dir * dynamic_distance \
+ up_dir * dynamic_height
# 平滑移动到目标位置
global_position = global_position.lerp(target_pos, follow_speed * delta)
# 看向摩托车前方稍远的位置(前瞻)
var look_target = _target.global_position + _target.basis.z * 3.0
var current_basis = global_transform.basis
var target_transform = global_transform.looking_at(look_target, Vector3.UP)
var target_basis = target_transform.basis
# 平滑旋转
var new_basis = Basis(
current_basis.x.lerp(target_basis.x, look_ahead_speed * delta),
current_basis.y.lerp(target_basis.y, look_ahead_speed * delta),
current_basis.z.lerp(target_basis.z, look_ahead_speed * delta)
)
global_transform = Transform3D(new_basis, global_position)分段计时系统基础
分段计时是拉力赛的灵魂。我们需要在每个检查点记录时间,最后汇总。
计时器代码
// 赛段计时管理器
public partial class StageTimer : Node
{
[Signal] public delegate void StageStartedEventHandler(int stageIndex);
[Signal] public delegate void CheckpointReachedEventHandler(int checkpointIndex, float time);
[Signal] public delegate void StageFinishedEventHandler(float totalTime);
private float _elapsedTime;
private bool _isRunning;
private int _currentStage;
private readonly List<float> _checkpointTimes = new();
public override void _Process(double delta)
{
if (!_isRunning) return;
_elapsedTime += (float)delta;
}
// 开始新赛段计时
public void StartStage(int stageIndex)
{
_currentStage = stageIndex;
_elapsedTime = 0f;
_isRunning = true;
_checkpointTimes.Clear();
EmitSignal(SignalName.StageStarted, stageIndex);
}
// 记录检查点时间
public void RecordCheckpoint(int index)
{
if (!_isRunning) return;
_checkpointTimes.Add(_elapsedTime);
EmitSignal(SignalName.CheckpointReached, index, _elapsedTime);
}
// 赛段结束
public void FinishStage()
{
_isRunning = false;
EmitSignal(SignalName.StageFinished, _elapsedTime);
}
// 获取格式化时间
public string GetFormattedTime()
{
int minutes = (int)(_elapsedTime / 60f);
float seconds = _elapsedTime % 60f;
return $"{minutes:D2}:{seconds:06.3f}";
}
}# 赛段计时管理器
extends Node
signal stage_started(stage_index: int)
signal checkpoint_reached(checkpoint_index: int, time: float)
signal stage_finished(total_time: float)
var _elapsed_time: float
var _is_running: bool
var _current_stage: int
var _checkpoint_times: Array[float] = []
func _process(delta):
if not _is_running:
return
_elapsed_time += delta
# 开始新赛段计时
func start_stage(stage_index: int):
_current_stage = stage_index
_elapsed_time = 0.0
_is_running = true
_checkpoint_times.clear()
stage_started.emit(stage_index)
# 记录检查点时间
func record_checkpoint(index: int):
if not _is_running:
return
_checkpoint_times.append(_elapsed_time)
checkpoint_reached.emit(index, _elapsed_time)
# 赛段结束
func finish_stage():
_is_running = false
stage_finished.emit(_elapsed_time)
# 获取格式化时间
func get_formatted_time() -> String:
var minutes = int(_elapsed_time / 60.0)
var seconds = fmod(_elapsed_time, 60.0)
return "%02d:%06.3f" % [minutes, seconds]主场景结构
把所有东西组合起来的主场景:
Main (Node3D)
├── WorldEnvironment ← 世界环境(天空、雾效)
├── DirectionalLight3D ← 主光源(太阳)
├── Track ← 赛道(地面、路障等)
├── Motorcycle (RigidBody3D) ← 摩托车
│ ├── MeshInstance3D ← 摩托车模型
│ ├── CollisionShape3D ← 碰撞形状
│ └── MotorcycleController ← 控制脚本
├── CameraRig ← 摄像机系统
│ ├── SpringArm3D
│ └── Camera3D
├── StageTimer ← 计时器
└── HUD (CanvasLayer) ← 游戏UI层常见问题
Q:为什么用 RigidBody3D 而不是 CharacterBody3D?
RigidBody3D 受物理引擎控制,可以自然地产生惯性、摩擦、碰撞反弹等效果,更适合赛车游戏。CharacterBody3D 需要你手动处理所有物理行为,工作量更大且不够真实。
Q:物理帧率设120会不会太耗性能?
对于大多数现代设备来说,120Hz 的物理计算完全没问题。物理计算的消耗远小于渲染。如果你非常在意低端设备,可以在设置中提供 60/120 的选项。
Q:摄像机跟随总是抖怎么办?
抖动通常是因为在 _process 里做物理相关的跟随。一定要在 _physics_process 里做摄像机跟随,这样和物理更新同步,就不会抖了。
下一步
项目搭好后,开始 摩托车物理。
