4. 拉力赛道设计
拉力赛道设计
拉力赛的赛道和场地赛车完全不同。场地赛车在同一个环道上转圈圈,而拉力赛的赛道是从A点到B点的一条路,跑过一遍就完了。这意味着每一条赛道都必须独一无二、充满特色。
本章你将学到
- 4种赛道类型的特色设计
- 赛道数据结构设计
- 检查点系统实现
- 分段计时与排名
- 赛道边界和捷径检测
四种赛道类型
拉力赛的魅力在于多样化的地形。我们设计4种不同风格的赛道:
各赛道特色一览
| 赛道类型 | 视觉风格 | 核心挑战 | 路面材质 | 推荐速度 |
|---|---|---|---|---|
| 沙漠 | 金黄沙丘、蓝天白云 | 沙尘暴降低能见度 | 沙地(低摩擦) | 中高速 |
| 森林 | 绿色密林、斑驳光影 | 窄道+树根障碍 | 泥地(中摩擦) | 中速 |
| 雪地 | 白雪皑皑、灰暗天空 | 极低抓地力+结冰 | 雪地(极低摩擦) | 低中速 |
| 山地 | 岩石悬崖、盘山公路 | 急弯+悬崖危险 | 碎石(中低摩擦) | 变速 |
赛道数据结构
一条赛道由很多数据组成:路点、检查点、路面类型、宽度等等。我们先设计好数据结构,后面实现起来才不会乱。
赛道数据类
// 赛道类型枚举
public enum TrackType { Desert, Forest, Snow, Mountain }
// 路面类型枚举
public enum SurfaceType { Asphalt, Dirt, Sand, Snow, Gravel, Ice }
// 检查点数据
public class CheckpointData
{
public string Name; // 检查点名称,比如 "CP1-骆驼谷"
public Vector3 Position; // 世界坐标
public float TargetTime; // 目标用时(秒)
public bool IsStart; // 是否是起点
public bool IsFinish; // 是否是终点
}
// 路段数据
public class RoadSegmentData
{
public Vector3 StartPoint; // 起点
public Vector3 EndPoint; // 终点
public SurfaceType Surface; // 路面类型
public float RoadWidth; // 路面宽度
public float Difficulty; // 难度系数(0~1)
}
// 赛道完整数据
[GlobalClass]
public partial class TrackData : Resource
{
[Export] public string TrackName = "沙漠之路";
[Export] public TrackType Type = TrackType.Desert;
[Export] public float TotalDistance = 5000f; // 总长度(米)
[Export] public Godot.Collections.Array<CheckpointData> Checkpoints = new();
[Export] public Godot.Collections.Array<RoadSegmentData> RoadSegments = new();
}# 赛道类型枚举
enum TrackType { DESERT, FOREST, SNOW, MOUNTAIN }
# 路面类型枚举
enum SurfaceType { ASPHALT, DIRT, SAND, SNOW, GRAVEL, ICE }
# 检查点数据
class CheckpointData:
var name: String # 检查点名称
var position: Vector3 # 世界坐标
var target_time: float # 目标用时(秒)
var is_start: bool # 是否是起点
var is_finish: bool # 是否是终点
# 路段数据
class RoadSegmentData:
var start_point: Vector3 # 起点
var end_point: Vector3 # 终点
var surface: int # 路面类型
var road_width: float # 路面宽度
var difficulty: float # 难度系数(0~1)
# 赛道完整数据
class_name TrackData
extends Resource
@export var track_name: String = "沙漠之路"
@export var type: int = TrackType.DESERT
@export var total_distance: float = 5000.0
@export var checkpoints: Array[CheckpointData] = []
@export var road_segments: Array[RoadSegmentData] = []检查点系统实现
检查点是赛道上的关键位置。玩家经过检查点时,系统记录时间,同时确认玩家没有抄近路。
检查点的工作原理
想象赛道是一条弯弯曲曲的绳子,每隔一段距离我们就打个结——这些"结"就是检查点。玩家必须按顺序经过所有检查点,否则成绩无效。
// 检查点控制器
public partial class CheckpointManager : Node3D
{
[Signal] public delegate void CheckpointPassedEventHandler(int index, float time);
[Signal] public delegate void TrackCompletedEventHandler(float totalTime);
[Export] public Array<NodePath> CheckpointPaths = new();
private int _nextCheckpoint = 0;
private float _startTime;
private readonly List<float> _checkpointTimes = new();
private bool _trackActive;
public override void _Ready()
{
// 给每个检查点连接信号
for (int i = 0; i < CheckpointPaths.Count; i++)
{
var area = GetNode<Area3D>(CheckpointPaths[i]);
int index = i; // 闭包需要局部变量
area.BodyEntered += (body) => OnCheckpointBodyEntered(body, index);
}
}
// 开始赛道
public void StartTrack()
{
_nextCheckpoint = 0;
_startTime = (float)Time.GetTicksMsec() / 1000f;
_checkpointTimes.Clear();
_trackActive = true;
GD.Print("赛道开始!");
}
private void OnCheckpointBodyEntered(Node3D body, int checkpointIndex)
{
if (!_trackActive) return;
// 只处理摩托车(通过组判断)
if (!body.IsInGroup("motorcycle")) return;
// 检查是否是下一个该过的检查点
if (checkpointIndex != _nextCheckpoint)
{
// 跳过了某个检查点,提示玩家
GD.Print($"检查点顺序错误!当前需要过第 {_nextCheckpoint} 个");
return;
}
// 记录时间
float currentTime = (float)Time.GetTicksMsec() / 1000f - _startTime;
_checkpointTimes.Add(currentTime);
GD.Print($"通过检查点 {checkpointIndex},用时:{FormatTime(currentTime)}");
EmitSignal(SignalName.CheckpointPassed, checkpointIndex, currentTime);
_nextCheckpoint++;
// 是否到达终点
if (_nextCheckpoint >= CheckpointPaths.Count)
{
_trackActive = false;
EmitSignal(SignalName.TrackCompleted, currentTime);
GD.Print($"赛道完成!总用时:{FormatTime(currentTime)}");
}
}
private string FormatTime(float time)
{
int minutes = (int)(time / 60f);
float seconds = time % 60f;
return $"{minutes:D2}:{seconds:06.3f}";
}
// 获取当前进度(0~1)
public float GetProgress()
{
if (CheckpointPaths.Count == 0) return 0f;
return (float)_nextCheckpoint / CheckpointPaths.Count;
}
}# 检查点控制器
extends Node3D
signal checkpoint_passed(index: int, time: float)
signal track_completed(total_time: float)
@export var checkpoint_paths: Array[NodePath] = []
var _next_checkpoint: int = 0
var _start_time: float
var _checkpoint_times: Array[float] = []
var _track_active: bool = false
func _ready():
# 给每个检查点连接信号
for i in range(checkpoint_paths.size()):
var area = get_node(checkpoint_paths[i]) as Area3D
area.body_entered.connect(_on_checkpoint_body_entered.bind(i))
# 开始赛道
func start_track():
_next_checkpoint = 0
_start_time = Time.get_ticks_msec() / 1000.0
_checkpoint_times.clear()
_track_active = true
print("赛道开始!")
func _on_checkpoint_body_entered(body: Node3D, checkpoint_index: int):
if not _track_active:
return
# 只处理摩托车
if not body.is_in_group("motorcycle"):
return
# 检查是否是下一个该过的检查点
if checkpoint_index != _next_checkpoint:
print("检查点顺序错误!当前需要过第 %d 个" % _next_checkpoint)
return
# 记录时间
var current_time = Time.get_ticks_msec() / 1000.0 - _start_time
_checkpoint_times.append(current_time)
print("通过检查点 %d,用时:%s" % [checkpoint_index, _format_time(current_time)])
checkpoint_passed.emit(checkpoint_index, current_time)
_next_checkpoint += 1
# 是否到达终点
if _next_checkpoint >= checkpoint_paths.size():
_track_active = false
track_completed.emit(current_time)
print("赛道完成!总用时:%s" % _format_time(current_time))
func _format_time(time: float) -> String:
var minutes = int(time / 60.0)
var seconds = fmod(time, 60.0)
return "%02d:%06.3f" % [minutes, seconds]
# 获取当前进度(0~1)
func get_progress() -> float:
if checkpoint_paths.size() == 0:
return 0.0
return float(_next_checkpoint) / checkpoint_paths.size()检查点场景节点结构
每个检查点用 Area3D 实现,放在赛道的关键位置:
Checkpoint (Area3D)
├── CollisionShape3D (BoxShape3D) ← 横跨赛道的触发区域
├── MeshInstance3D ← 可选:检查点标记(旗帜/拱门)
└── CheckpointVisual ← 可选:通过时的视觉反馈BoxShape3D 的大小应该横跨整个赛道宽度,高度足够让摩托车通过时被检测到。比如:宽 10米 x 高 4米 x 厚 2米。
分段计时排名
拉力赛不是一圈圈地比,而是每段路单独计时,最后汇总。这就需要一个排名系统:
// 排名管理器
public partial class RankingManager : Node
{
// 一个骑手的总成绩
public class RiderScore
{
public string Name;
public List<float> StageTimes = new(); // 每个赛段的用时
public float TotalTime => StageTimes.Sum(); // 总用时
}
private readonly List<RiderScore> _scores = new();
// 添加一个骑手
public void AddRider(string name)
{
_scores.Add(new RiderScore { Name = name });
}
// 记录赛段用时
public void RecordStageTime(string riderName, float time)
{
var score = _scores.Find(s => s.Name == riderName);
if (score != null)
{
score.StageTimes.Add(time);
}
}
// 获取当前排名(按总用时从少到多)
public List<RiderScore> GetRankings()
{
var sorted = _scores.OrderBy(s => s.TotalTime).ToList();
// 输出排名
for (int i = 0; i < sorted.Count; i++)
{
var rider = sorted[i];
GD.Print($"第{i + 1}名: {rider.Name} - {FormatTime(rider.TotalTime)}");
}
return sorted;
}
private string FormatTime(float time)
{
int minutes = (int)(time / 60f);
float seconds = time % 60f;
return $"{minutes:D2}:{seconds:06.3f}";
}
}# 骑手成绩数据
class RiderScore:
var name: String
var stage_times: Array[float] = []
# 总用时
var total_time: float:
get:
var sum = 0.0
for t in stage_times:
sum += t
return sum
# 排名管理器
extends Node
var _scores: Array[RiderScore] = []
# 添加一个骑手
func add_rider(rider_name: String):
var score = RiderScore.new()
score.name = rider_name
_scores.append(score)
# 记录赛段用时
func record_stage_time(rider_name: String, time: float):
for score in _scores:
if score.name == rider_name:
score.stage_times.append(time)
return
# 获取当前排名(按总用时从少到多)
func get_rankings() -> Array[RiderScore]:
_scores.sort_custom(func(a, b): return a.total_time < b.total_time)
for i in range(_scores.size()):
var rider = _scores[i]
print("第%d名: %s - %s" % [i + 1, rider.name, _format_time(rider.total_time)])
return _scores
func _format_time(time: float) -> String:
var minutes = int(time / 60.0)
var seconds = fmod(time, 60.0)
return "%02d:%06.3f" % [minutes, seconds]赛道边界和捷径检测
玩家可能会试图离开赛道抄近路。我们需要两套机制来防止作弊:
1. 检查点顺序验证
上面的代码已经实现了——如果玩家跳过了某个检查点(比如从 CP1 直接到了 CP3),系统不会承认,必须回头去过 CP2。
2. 赛道边界检测
在赛道两侧放置看不见的触发区域,玩家离开赛道时给出警告或惩罚:
// 赛道边界检测
public partial class TrackBoundary : Area3D
{
[Export] public float PenaltyTime = 5f; // 离开赛道的罚时(秒)
[Signal] public delegate void PlayerOffTrackEventHandler(float penalty);
private bool _isOffTrack;
private float _offTrackTimer;
public override void _Process(double delta)
{
if (_isOffTrack)
{
_offTrackTimer += (float)delta;
// 离开赛道超过2秒才开始罚时
if (_offTrackTimer > 2f)
{
GD.Print("离开赛道!每秒罚时 " + PenaltyTime + " 秒");
EmitSignal(SignalName.PlayerOffTrack, PenaltyTime * (float)delta);
}
}
}
private void _OnBodyExited(Node3D body)
{
if (body.IsInGroup("motorcycle"))
{
_isOffTrack = true;
_offTrackTimer = 0f;
}
}
private void _OnBodyEntered(Node3D body)
{
if (body.IsInGroup("motorcycle"))
{
_isOffTrack = false;
_offTrackTimer = 0f;
}
}
}# 赛道边界检测
extends Area3D
@export var penalty_time: float = 5.0 # 离开赛道的罚时(秒)
signal player_off_track(penalty: float)
var _is_off_track: bool = false
var _off_track_timer: float = 0.0
func _process(delta):
if _is_off_track:
_off_track_timer += delta
# 离开赛道超过2秒才开始罚时
if _off_track_timer > 2.0:
print("离开赛道!每秒罚时 %.1f 秒" % penalty_time)
player_off_track.emit(penalty * delta)
func _on_body_exited(body: Node3D):
if body.is_in_group("motorcycle"):
_is_off_track = true
_off_track_timer = 0.0
func _on_body_entered(body: Node3D):
if body.is_in_group("motorcycle"):
_is_off_track = false
_off_track_timer = 0.0赛道构建技巧
用 Path3D 构建赛道路线
Godot 的 Path3D 节点是构建赛道的利器:
- 添加
Path3D节点 - 在场景中添加控制点,画出赛道走向
- 用
CSGPolygon3D或导入模型生成路面网格 - 在关键位置放置检查点
赛道装饰
赛道两侧需要丰富的装饰来增加沉浸感:
| 赛道类型 | 装饰元素 |
|---|---|
| 沙漠 | 仙人掌、岩石、沙丘、骆驼队 |
| 森林 | 大树、灌木、蘑菇、小溪 |
| 雪地 | 松树(挂雪)、雪人、冰块 |
| 山地 | 岩石、悬崖护栏、山洞 |
性能提示
远处的装饰物用 MultiMeshInstance3D 批量渲染,近处的才用独立节点。这样可以在不降低视觉效果的情况下大幅提升性能。
常见问题
Q:赛道怎么做弯道?
用 Path3D 的贝塞尔曲线控制点。在弯道处多放几个控制点,调整切线方向就能做出平滑的弯道。
Q:检查点间隔多大合适?
一般每隔 200~500 米放一个检查点。太密会让玩家觉得被打断,太稀又无法有效防止抄近路。
Q:怎么让赛道有高低起伏?
在 Path3D 的控制点中调整 Y 坐标(高度)。山路赛道的起伏最大,沙漠赛道可以有一些小沙丘。
下一步
赛道设计完成后,开始 地形与天气。
