目标:把“无限圆柱世界 + 轻量物理 + 清晰手感”落成稳定架构,玩法好调 、模块易扩 、性能可控 。本文对齐项目空间里的脚本与资源约定,总结为一篇能直接放进博客的技术复盘。 仓库:https://github.com/xiaoyunhai0/3DRacing
一、项目概览
二、目录结构与资源约定 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; [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 ; timeAcc = 0f ; gameOver = false ; UpdateScoreLabel(); UpdateTimeLabel(0 ); } private void Update () { if (gameOver) return ; timeAcc += Time.deltaTime; UpdateTimeLabel(timeAcc); } #endregion #region Score & Time public void AddScore (int delta = 1 ) { if (gameOver) return ; score += delta; UpdateScoreLabel(); 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 } " ; } #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 () { 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 ); 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 ; public float obstacleChanceAcceleration = 0.2f ; public int gateChance = 8 ; [Header("Visibility" ) ] public float showItemDistance = 60f ; public float shadowHeight = 0f ; #endregion #region Runtime private readonly List<GameObject> pieces = new (); private float perlinOffset; #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 () { var piece = CreateCylinderPiece(); 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); SpawnItemsOn(piece.transform); 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 () { 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 ; 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; 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 ) { 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; 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 () { 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); } UpdateTargetRotation(); var euler = new Vector3(transform.localEulerAngles.x, targetRotation, transform.localEulerAngles.z); transform.rotation = Quaternion.RotateTowards(transform.rotation, Quaternion.Euler(euler), rotateSpeed * Time.deltaTime); onGround = RearGrounded(); if (onGround) { if (!grassFx.isPlaying) grassFx.Play(); } else { if (grassFx.isPlaying) grassFx.Stop(); } skidRoutineOn = onGround && Mathf.Abs(targetRotation) > rotationAngle * 0.5f ; 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 () { 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 ; Vector3 wantedPos = target.position - target.forward * distance + Vector3.up * height; transform.position = Vector3.Lerp(transform.position, wantedPos, Time.deltaTime * rotationDamping); 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 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 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 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
dimensions:x 决定圆滑程度,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.2、waveHeight 提到 0.8+,相机 rotationDamping 略降。
六、落地步骤
场景搭好 :主摄像机、车(含 Rigidbody+WheelCollider+Mesh 对齐)、灯光、GameManager、WorldGenerator。
UI(TMP) :把分数与时间字段连到 TMP_Text;确认字体资产就绪。
预制体 :Gate(小触发盒 + 实碰撞体)、Obstacle_Spike;统一打 Item。
脚本挂载 :
车:Car(填好 Wheels、特效、WorldRef)。
世界:WorldGenerator(填好预制体、参数)。
摄像机:CameraFollow(Target/LookAt)。
Gate/Obstacle:分别连 GameManager。
标签 :车根对象标 Player;动态物体打 Item。
播放 :左右半屏/方向键转向,穿门加分,撞障/翻车结算 → 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 把分数、时间、结算与重启串成闭环。