3DRacing 可复用架构实战:圆柱地形·两片段循环·一键停世界

目标:把“无限圆柱世界 + 轻量物理 + 清晰手感”落成稳定架构,玩法好调模块易扩性能可控。本文对齐项目空间里的脚本与资源约定,总结为一篇能直接放进博客的技术复盘。
仓库:https://github.com/xiaoyunhai0/3DRacing

一、项目概览

  • 玩法:沿圆柱隧道疾行,穿过 Gate 加分、触碰 Obstacle 或翻车则 Game Over;秒表按 MM:SS 展示;延迟 0.6s 重开场景形成“有呼吸的复位”。

  • 核心系统

    • WorldGenerator:Perlin 噪声卷圆 → 两片段循环(只保留“当前+下一段”)。
    • BasicMovement:把“前进/旋转”统一挂在世界和灯光上(世界动,车相对静),并支持一键清零。
    • Car:双输入(鼠标左右半屏/键盘水平轴)、WheelCollider 对齐轮胎、草屑/胎印、离地下压力、解体反馈。
    • CameraFollow:启动期低阻尼,首次输入后恢复常规阻尼。
    • GameManager:分数/时间/UI/音效/一键停世界/重启。
    • Gate & Obstacle:触发计分;实体碰撞终止。
  • 工程策略TMP 优先(旧 Text 仅兜底)、协程分帧(换段让一帧)、可见性/阴影截断(只渲染近处、只给下半圆投影)。


二、目录结构与资源约定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Assets/
├─ Scripts/Racing/
│ ├─ GameManager.cs
│ ├─ WorldGenerator.cs
│ ├─ BasicMovement.cs
│ ├─ Car.cs
│ ├─ CameraFollow.cs
│ ├─ Gate.cs
│ ├─ Obstacle.cs
│ └─ CarGameOverTrigger.cs
├─ Prefabs/
│ ├─ Car.prefab # 轮胎 Mesh 与 WheelCollider 一一对应
│ ├─ Gate.prefab # 触发器盒 + 实碰撞体(用于被撞反应)
│ └─ Obstacle_Spike.prefab
├─ Materials/ & Shaders/
├─ Audio/
│ ├─ Background loop.wav
│ ├─ Score.mp3
│ └─ Game over.wav
└─ UI/
└─ TextMeshPro/Fonts

命名与标签

  • 场景中所有动态要素统一打 Item 标签(便于生成器做可见性/阴影与批量停止)。
  • Gate 的 Trigger小而居中,同时保留一个轻量常规碰撞体用于被撞后的物理反馈。
  • 车顶圆形触发器名字含 World piece 判翻车(或用 Layer/Tag 更稳)。

三、运行时数据流与分层

1
2
3
4
5
6
7
8
9
10
11
12
Player Input   UI (TMP_Text)
│ │
▼ │
Car (输入/轮胎/特效/解体) GameManager (分数/时间/UI/音效/停止/重启)
│ ▲
├─────────────┘


WorldGenerator ──▶ BasicMovement(世界/灯光统一运动)

├─ Gate / Obstacle 生成与可见性/阴影截断
└─ 两片段循环(协程分帧、继承旋转)

四、实现细节与关键脚本

说明:以下片段与常见实现保持一致,不强耦合资源,可以平滑落到你的工程。所有字段命名均为示例,可在 Inspector 对齐后使用。

1) GameManager:分数/计时/一键停世界/重启

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
using UnityEngine;
using UnityEngine.SceneManagement;
using TMPro;

public class GameManager : MonoBehaviour
{
#region Inspector
[Header("UI References (TMP 优先)")]
[SerializeField] private TMP_Text scoreLabel; // 分数文本
[SerializeField] private TMP_Text timeLabel; // 计时文本
[SerializeField] private GameObject gameOverPanel; // 结算面板
[SerializeField] private Animator uiAnimator; // UI 动画(得分晕光等)
[Header("Audio")]
[SerializeField] private AudioSource sfxScore; // 加分音效
[SerializeField] private AudioSource sfxGameOver; // 结束音效
[Header("Flow")]
[SerializeField] private float reloadDelay = 0.6f; // 重开延迟
#endregion

#region Runtime
private int score; // 当前分
private float timeAcc; // 秒表累积
private bool gameOver; // 是否已结算
#endregion

#region Unity
private void Start()
{
score = 0; // 初始化分数为 0
timeAcc = 0f; // 清零秒表
gameOver = false; // 开始未结束
UpdateScoreLabel(); // 刷一次 UI
UpdateTimeLabel(0); // 刷一次时间
}

private void Update()
{
if (gameOver) return; // 已结束则不再计时
timeAcc += Time.deltaTime; // 秒表累加
UpdateTimeLabel(timeAcc); // 刷新 MM:SS
}
#endregion

#region Score & Time
public void AddScore(int delta = 1)
{
if (gameOver) return; // 结束后不再加分
score += delta; // 分数累加
UpdateScoreLabel(); // 刷 UI
uiAnimator?.SetTrigger("Score"); // 播得分动画
if (sfxScore) sfxScore.Play(); // 播音效
}

private void UpdateScoreLabel() =>
scoreLabel.text = score.ToString(); // 直接显示数字

private void UpdateTimeLabel(float seconds)
{
int m = Mathf.FloorToInt(seconds / 60f); // 分
int s = Mathf.FloorToInt(seconds % 60f); // 秒
timeLabel.text = $"{m:00}:{s:00}"; // MM:SS
}
#endregion

#region GameOver & Reload
public void GameOver()
{
if (gameOver) return; // 防重复
gameOver = true; // 标记结束
sfxGameOver?.Play(); // 播音效
FreezeWorld(); // 一键停世界
gameOverPanel?.SetActive(true); // 打开结算面板
Invoke(nameof(Reload), reloadDelay); // 延迟重开
}

private void FreezeWorld()
{
// 遍历场景中所有 BasicMovement,将位移与旋转速度清零
foreach (var mv in FindObjectsOfType<BasicMovement>())
{
mv.movespeed = 0f; // 速度清零
mv.rotateSpeed = 0f; // 旋转清零
}
}

private void Reload()
{
var idx = SceneManager.GetActiveScene().buildIndex; // 当前场景索引
SceneManager.LoadScene(idx); // 重载当前场景
}
#endregion
}

2) WorldGenerator:两片段循环 + Perlin 卷圆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WorldGenerator : MonoBehaviour
{
#region Inspector
[Header("Grid Shape")]
public Vector2Int dimensions = new(32, 48); // x=圆周细分, y=前进细分
public float scale = 0.8f; // 网格单位间距
[Header("Perlin")]
public float perlinScale = 0.6f; // 粗糙度(越大越颠)
public float waveHeight = 0.6f; // 丘高
public float randomness = 0.5f; // 段间差异
[Header("Items")]
public GameObject[] obstacles; // 障碍预制体数组
public GameObject gate; // 计分门预制体
public int startObstacleChance = 5; // 初始概率(1/N)
public float obstacleChanceAcceleration = 0.2f; // 段与段之间递增
public int gateChance = 8; // 门的概率(1/N)
[Header("Visibility")]
public float showItemDistance = 60f; // 可见距离阈值
public float shadowHeight = 0f; // 阴影仅开在此高度以下
#endregion

#region Runtime
private readonly List<GameObject> pieces = new(); // 当前两段
private float perlinOffset; // Perlin 偏移
#endregion

#region Unity
private void Start()
{
GenerateWorldPiece(); // 先造第一段
GenerateWorldPiece(); // 再造第二段
StartCoroutine(LoopPieces()); // 循环协程
}
#endregion

#region Piece Loop
private float SegmentLength() =>
dimensions.y * scale * Mathf.PI; // 段长≈圆周长度(前进方向)

private void GenerateWorldPiece()
{
// 1) 生成圆柱网格
var piece = CreateCylinderPiece();
// 2) 放到末尾:如果已有段,则按段长顺延
if (pieces.Count > 0)
{
var last = pieces[^1].transform;
piece.transform.position = last.position + Vector3.forward * SegmentLength();
piece.transform.rotation = last.rotation; // 继承旋转,接缝平滑
}
pieces.Add(piece);

// 3) 随机刷障碍/门(仅示意)
SpawnItemsOn(piece.transform);

// 4) 子物体加 BasicMovement,统一受控
var mv = piece.AddComponent<BasicMovement>();
mv.movespeed = -10f; // 世界向玩家推近
mv.rotateSpeed = 30f; // 旋转基准(可与灯光同值)
}

private IEnumerator LoopPieces()
{
// 循环:当“第二段”来到相机前方时,销毁最前段并补一段
var cam = Camera.main;
while (true)
{
if (pieces.Count >= 2)
{
var second = pieces[1].transform;
if (second.position.z <= cam.transform.position.z)
{
Destroy(pieces[0]); // 删最前
pieces.RemoveAt(0); // 出队
GenerateWorldPiece(); // 末尾再补一段
yield return null; // 让一帧,降低尖峰
}
}
UpdateVisibilityAndShadows(); // 每帧更新可见与阴影
yield return null;
}
}
#endregion

#region Mesh & Items
private GameObject CreateCylinderPiece()
{
// 生成一个包含 MeshFilter/Renderer/Collider 的圆柱片段
var go = new GameObject("World piece");
var mf = go.AddComponent<MeshFilter>();
var mr = go.AddComponent<MeshRenderer>();
var mc = go.AddComponent<MeshCollider>();

// 顶点/三角
var mesh = new Mesh { name = "CylinderSegment" };
mf.sharedMesh = mesh;

// 按平面→卷圆思路填充网格
BuildCylinderMesh(mesh);

mc.sharedMesh = mesh;
return go;
}

private void BuildCylinderMesh(Mesh mesh)
{
// 计算参数
int xCount = dimensions.x; // 圆周细分
int zCount = dimensions.y; // 前进细分
float r = xCount * scale * 0.5f; // 半径
var verts = new Vector3[(xCount + 1) * (zCount + 1)];
var tris = new int[xCount * zCount * 6];

// 顶点:先建平面,再“卷圆”
int idx = 0;
for (int z = 0; z <= zCount; z++)
{
for (int x = 0; x <= xCount; x++)
{
float theta = (x / (float)xCount) * Mathf.PI * 2f; // 角度
// 平面坐标
float px = r * Mathf.Cos(theta);
float pz = r * Mathf.Sin(theta);
float py = 0f;

// Perlin 噪声沿“朝圆心方向”推一个位移,形成草丘
float nx = (px + perlinOffset) * perlinScale;
float nz = (pz + perlinOffset) * perlinScale;
float noise = Mathf.PerlinNoise(nx, nz) * waveHeight;

var center = Vector3.zero;
var dirIn = (center - new Vector3(px, py, pz)).normalized;
var pos = new Vector3(px, py, pz) + dirIn * noise;

// 前进方向用 z 计数形成“长度≈π*zCount*scale”
pos += Vector3.forward * (z * scale * Mathf.PI);

verts[idx++] = pos;
}
}

// 三角索引
int t = 0;
for (int z = 0; z < zCount; z++)
{
for (int x = 0; x < xCount; x++)
{
int i0 = z * (xCount + 1) + x;
int i1 = i0 + 1;
int i2 = i0 + (xCount + 1);
int i3 = i2 + 1;

tris[t++] = i0; tris[t++] = i2; tris[t++] = i1;
tris[t++] = i1; tris[t++] = i2; tris[t++] = i3;
}
}

mesh.Clear();
mesh.vertices = verts;
mesh.triangles = tris;
mesh.RecalculateNormals();

// 偏移迭代:下一段会不同
perlinOffset += randomness;
}

private void SpawnItemsOn(Transform piece)
{
// 简化:仅示范“1/N 概率”刷障碍/门,并把实例作为 piece 的子物体
int obstacleChance = Mathf.Max(1, startObstacleChance);
for (int i = 0; i < 6; i++) // 每段少量刷点
{
if (Random.Range(1, obstacleChance + 1) == 1 && obstacles.Length > 0)
{
var prefab = obstacles[Random.Range(0, obstacles.Length)];
var p = Instantiate(prefab, piece);
p.tag = "Item";
p.transform.localPosition = new Vector3(0f, 0f, i * (SegmentLength() / 6f));
}
else if (Random.Range(1, gateChance + 1) == 1 && gate)
{
var g = Instantiate(gate, piece);
g.tag = "Item";
g.transform.localPosition = new Vector3(0f, 0f, i * (SegmentLength() / 6f));
}
}
// 让概率随段增长而变“更容易生成”
startObstacleChance = Mathf.Max(1, Mathf.RoundToInt(startObstacleChance - obstacleChanceAcceleration));
}
#endregion

#region Visibility & Shadow
private void UpdateVisibilityAndShadows()
{
// 只显示近处物体;只给下半圆的物体开阴影
foreach (var r in FindObjectsOfType<Renderer>())
{
if (!r || !r.transform.CompareTag("Item")) continue;
bool visible = (r.transform.position.z - Camera.main.transform.position.z) < showItemDistance;
r.enabled = visible;

// 阴影:仅当 y < shadowHeight 时才投影
r.shadowCastingMode = (r.transform.position.y <= shadowHeight)
? UnityEngine.Rendering.ShadowCastingMode.On
: UnityEngine.Rendering.ShadowCastingMode.Off;
}
}
#endregion
}

3) BasicMovement:统一世界/灯光的前进与旋转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using UnityEngine;

public class BasicMovement : MonoBehaviour
{
#region Inspector
public float movespeed = -10f; // 负值=世界向玩家推近
public float rotateSpeed = 30f; // 旋转速度(世界与灯光可同值)
public bool lamp = false; // 若为灯光,旋转轴不同
public Transform carTransform; // 读取车体转角,制造滚动感
public int rotationAngle = 30; // 车体最大转角(归一化用)
#endregion

#region Unity
private void Update()
{
// 沿前进方向统一平移
transform.Translate(Vector3.forward * movespeed * Time.deltaTime);

// 根据车体转角给世界/灯光一个与之成比例的旋转
if (carTransform)
{
float y = carTransform.localEulerAngles.y;
if (y > rotationAngle * 2f) y = (360 - y) * -1f; // 归一化到 [-?, ?]
float k = y / rotationAngle; // 比例系数
Vector3 axis = lamp ? Vector3.right : Vector3.forward;
transform.Rotate(axis * -rotateSpeed * k * Time.deltaTime);
}
}
#endregion
}

4) Car:双输入、轮胎同步、草屑/胎印、离地下压力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
using System.Collections;
using UnityEngine;

public class Car : MonoBehaviour
{
#region Inspector
[Header("Wheels")]
public WheelCollider[] wheelColliders; // 物理轮胎
public Transform[] wheelMeshes; // 可见轮胎
public float wheelRotateSpeed = 360f; // 视觉滚动

[Header("Steer")]
public int rotationAngle = 30; // 最大目标转角
public float rotateSpeed = 180f; // 插值速度

[Header("Effects")]
public ParticleSystem grassFx; // 草屑
public Transform[] skidPivots; // 胎印生成点
public GameObject skidPrefab; // 胎印预制体
public float skidInterval = 0.12f; // 胎印节流
public float skidSize = 1.0f; // 胎印大小

[Header("Downforce")]
public Transform back; // 车尾位置
public float constantBackForce = 60f; // 向下力

[Header("WorldRef")]
public WorldGenerator generator; // 供归属胎印
#endregion

#region Runtime
private int targetRotation; // 目标转角
private bool onGround; // 是否着地
private bool skidRoutineOn; // 是否画胎印
#endregion

#region Unity
private void OnEnable()
{
StartCoroutine(SkidRoutine()); // 启动胎印协程(按开关节流)
}

private void LateUpdate()
{
// 1) 轮胎 Mesh 对齐 Collider
for (int i = 0; i < wheelColliders.Length && i < wheelMeshes.Length; i++)
{
wheelColliders[i].GetWorldPose(out var pos, out var rot);
wheelMeshes[i].SetPositionAndRotation(pos, rot);
wheelMeshes[i].Rotate(Vector3.right * Time.deltaTime * wheelRotateSpeed);
}

// 2) 输入 → 目标转角
UpdateTargetRotation();

// 3) 旋转插值
var euler = new Vector3(transform.localEulerAngles.x, targetRotation, transform.localEulerAngles.z);
transform.rotation = Quaternion.RotateTowards(transform.rotation, Quaternion.Euler(euler), rotateSpeed * Time.deltaTime);

// 4) 简易地面检测(两后轮 Raycast)
onGround = RearGrounded();

// 5) 草屑/胎印逻辑
if (onGround) { if (!grassFx.isPlaying) grassFx.Play(); }
else { if (grassFx.isPlaying) grassFx.Stop(); }

skidRoutineOn = onGround && Mathf.Abs(targetRotation) > rotationAngle * 0.5f;

// 6) 离地下压力(两后轮皆离地时更显著)
if (!onGround && back)
{
GetComponent<Rigidbody>().AddForceAtPosition(Vector3.down * constantBackForce, back.position, ForceMode.Force);
skidRoutineOn = false; // 空中不画胎印
}
}
#endregion

#region Input & Helpers
private void UpdateTargetRotation()
{
float h = Input.GetAxis("Horizontal"); // 键盘/手柄水平轴
if (Mathf.Abs(h) > 0.01f)
{
targetRotation = Mathf.RoundToInt(rotationAngle * h);
return;
}
if (Input.GetMouseButton(0)) // 鼠标左键按住→左右半屏
{
targetRotation = (Input.mousePosition.x > Screen.width * 0.5f) ? rotationAngle : -rotationAngle;
return;
}
targetRotation = 0; // 无输入回正
}

private bool RearGrounded()
{
// 可替换为轮胎触地状态;这里示例用 Raycast
int hits = 0;
foreach (var w in wheelMeshes)
{
if (Physics.Raycast(w.position, Vector3.down, out _, 0.3f)) hits++;
}
return hits >= 2; // 至少两后轮命中
}

private IEnumerator SkidRoutine()
{
while (true)
{
yield return new WaitForSeconds(skidInterval);
if (!skidRoutineOn || skidPrefab == null || skidPivots == null || generator == null) continue;

foreach (var p in skidPivots)
{
var go = Instantiate(skidPrefab, p.position, p.rotation);
// 关键:胎印作为“当前世界片段”的子物体,保证相对滑移正确
go.transform.SetParent(generator.transform, true);
go.transform.localScale = new Vector3(1f, 1f, 4f) * skidSize;
}
}
}
#endregion

#region API
public void FallApart(GameObject ragdollPrefab)
{
if (!ragdollPrefab) return;
Instantiate(ragdollPrefab, transform.position, transform.rotation); // 生成残骸
gameObject.SetActive(false); // 隐藏整车
}
#endregion
}

5) CameraFollow:启动期低阻尼 & 首次输入恢复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using UnityEngine;

public class CameraFollow : MonoBehaviour
{
#region Inspector
public Transform target; // 跟随目标(车)
public Transform lookAt; // 注视点(车头/偏前)
public float distance = 10f; // 距离
public float height = 3f; // 高度
public float rotationDamping = 3f; // 常规阻尼
public float startDamping = 0.1f; // 启动期低阻尼
#endregion

#region Runtime
private float originalDamping;
private bool firstInputTriggered;
#endregion

#region Unity
private void Start()
{
originalDamping = rotationDamping; // 记录原值
rotationDamping = startDamping; // 启动期更“黏”
}

private void Update()
{
// 监听玩家首次输入,恢复正常阻尼
if (!firstInputTriggered &&
(Mathf.Abs(Input.GetAxis("Horizontal")) > 0.01f || Input.GetMouseButtonDown(0)))
{
rotationDamping = originalDamping;
firstInputTriggered = true;
}
}

private void LateUpdate()
{
if (!target) return;

// 1) 目标位置
Vector3 wantedPos = target.position - target.forward * distance + Vector3.up * height;
transform.position = Vector3.Lerp(transform.position, wantedPos, Time.deltaTime * rotationDamping);

// 2) 注视目标
if (lookAt) transform.rotation = Quaternion.Slerp(transform.rotation,
Quaternion.LookRotation(lookAt.position - transform.position), Time.deltaTime * rotationDamping);
}
#endregion
}

6) Gate / Obstacle / 翻车触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Gate.cs
using UnityEngine;

public class Gate : MonoBehaviour
{
#region Inspector
public GameManager gm;
public AudioSource sfx;
#endregion

private void OnTriggerEnter(Collider other)
{
if (!other.transform.root.CompareTag("Player")) return; // 只认玩家
gm?.AddScore(1); // 加分
if (sfx) sfx.Play(); // 播音
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Obstacle.cs
using UnityEngine;

public class Obstacle : MonoBehaviour
{
#region Inspector
public GameManager gm;
#endregion

private void OnCollisionEnter(Collision collision)
{
if (!collision.transform.root.CompareTag("Player")) return; // 只认玩家
gm?.GameOver(); // 游戏结束
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// CarGameOverTrigger.cs(车顶小圆触发器判翻车)
using UnityEngine;

public class CarGameOverTrigger : MonoBehaviour
{
#region Inspector
public GameManager gm;
#endregion

private void OnTriggerEnter(Collider other)
{
if (!other.name.Contains("World piece")) return; // 简单命名匹配
gm?.GameOver();
}
}

五、参数手册

WorldGenerator

  • dimensionsx 决定圆滑程度,y 影响“段长 = y * scale * π”。
  • scale:网格密度与视觉“像素”。
  • perlinScale:地形粗糙度(越大起伏越频繁)。
  • waveHeight:丘高。
  • randomness:段间差异度。
  • startObstacleChance / obstacleChanceAcceleration / gateChance:障碍/门的概率(用“1/N”表述,数值越大越稀)。
  • showItemDistance / shadowHeight:只显示近处、只给下半圆投影。

BasicMovement

  • movespeed:世界统一前进(通常为负值)。
  • rotateSpeed:滚动错觉强弱;灯光与世界可用相同基准。
  • rotationAngle:与车体转角配合,决定滚动的“灵敏度”。

Car

  • rotationAngle / rotateSpeed:手感的“灵敏+稳定”平衡。
  • wheelRotateSpeed:视觉轮胎快慢。
  • skidInterval / skidSize:胎印节奏与大小。
  • constantBackForce:离地下压力大小。

CameraFollow

  • startDamping:启动期阻尼(小=更黏,避免突变)。
  • rotationDamping:常规阻尼(跟拍稳定性)。
  • distance / height:机位。

快速配方dimensions=(32,48), scale=0.8, perlinScale≈0.6, waveHeight≈0.6, movespeed≈-10, rotateSpeed≈30
想“更漂移”:perlinScale 提到 0.9~1.2waveHeight 提到 0.8+,相机 rotationDamping 略降。


六、落地步骤

  1. 场景搭好:主摄像机、车(含 Rigidbody+WheelCollider+Mesh 对齐)、灯光、GameManagerWorldGenerator

  2. UI(TMP):把分数与时间字段连到 TMP_Text;确认字体资产就绪。

  3. 预制体Gate(小触发盒 + 实碰撞体)、Obstacle_Spike;统一打 Item

  4. 脚本挂载

    • 车:Car(填好 Wheels、特效、WorldRef)。
    • 世界:WorldGenerator(填好预制体、参数)。
    • 摄像机:CameraFollow(Target/LookAt)。
    • Gate/Obstacle:分别连 GameManager
  5. 标签:车根对象标 Player;动态物体打 Item

  6. 播放:左右半屏/方向键转向,穿门加分,撞障/翻车结算 → 0.6s 重开。


七、性能与稳定性

  • 两片段上限:永远只保留两段世界;换段放协程里 yield return null 减峰值。
  • 可见性/阴影:只渲染近处,阴影只给下半圆,消灭“幽灵影子”。
  • 一键停世界:GameOver 遍历 BasicMovement,清零 move/rotate,避免“漏停”。
  • TMP 优先:文本统一用 TMP;旧 Text 仅兜底(迁移时最容易 Null)。
  • 轮胎顺序WheelCollider[]wheelMeshes[] 必须一一对应。

八、排错表

现象 常见原因 处理方式
分数/时间不更新 绑定的是旧 Text 或字段未连上 改用 TMP_Text 并重新拖拽引用
撞上 Gate 也 +1 Gate 触发盒过大/未居中 缩小触发盒、居中,保留实体碰撞体仅用于被撞反应
世界不“滚” 灯光未挂 BasicMovement 或车体未赋值 给灯光也挂脚本;carTransform 指向车体
接缝有“台阶” 段长/旋转未继承或网格细分过低 继承 rotation;适当加大 dimensions.y
胎印漂移错位 胎印未挂到当前世界片段 生成后 SetParent(generator.transform, true)
翻车不触发 名称/Layer 不匹配 用 Layer/Tag 代替“包含名”判断更稳

九、扩展路线

  • 难度带:随分数/时间调整 gateChance / startObstacleChance / obstacleChanceAcceleration,做“阶梯上行”。
  • 内容规则器:按“坡度/曲率/速度”决定障碍与 Gate 的组合,做轻量 Rule-Based 刷法。
  • 输入融合:移动端加陀螺仪;PC 保留半屏/键盘兜底。
  • 表现升级:胎印长度/透明度与“侧滑角×速度”联动;相机微抖/动态 FOV。
  • 数据打点:记录“死亡原因/位置/速度”,为关卡概率与摆放优化提供证据。
  • 可视化调参:把关键参数打包为 ScriptableObject(如 GameBalance.asset),写个自定义 Inspector。

结语

把复杂留给世界(生成+运动),把简单留给手感(车+相机)
两片段循环让资源与逻辑都保持极简;车体输入与特效在稳定框架里“随需加料”;GameManager 把分数、时间、结算与重启串成闭环。