0%

Unity指尖赛车

指尖赛车

前言

Unity 3D无尽小车项目基本点

  • 使用Collider,Rigidbody等物理组件,能做出简单的物理模拟。
  • 根据柏林噪声自动生成地形,可以无限不重复的进行下去。地图采用了2个圆柱地形轮换,属于小型内存池,节省内存空间
  • 使用UGUI制作游戏界面,使用和制作Animation,并且用状态机播放动画。
  • 使用声音和粒子特效增强游戏效果,可以制作简单粒子特效。

该项目主要学习内容在于代码生产地形地图的运用,资源包已经扒好了

项目地址: 传送门, 演示视频在DEMO文件夹下。

小车主体

拼装小车模型

车身主题,设置为刚体,不整车设置为碰撞体,设置前保险杠,车身和车顶三个碰撞体

子类里放汽车网格,往上拖材质,车轮的网格和车身的分开,在设置轮胎的碰撞器

车身特效也挂在车身下,meshfliter和render是成对使用的。

汽车使用Unity的内置车轮碰撞器进行悬架物理模拟,网格是分开的。因此汽车很容易更换。

有Wheel Colliders、Car Meshes和Body Colliders将所有对象都放在单独的父对象下。要更改汽车,最好简单地更换Wheel Mesh和Car Mesh。对于Wheel Meshes,在将它们分配给汽车脚本时一定要确保数组保持相同的顺序,以便它们正确地对应于Wheel Colliders。Body Mesh只要摆正位置就可以了。

这些碰撞器根据实际网格调整大小,无论何时如果你改变了轮胎尺寸或车身尺寸,请确保添加新的碰撞器或重新调整现有的来匹配汽车。另外请注意前面的碰撞盒。这个盒子应该保持汽车稳定,以防它撞到汽车的前端。此外,车顶的大圆形对撞器是一个触发器,当汽车翻转时,它就会被触发。这样,当汽车失去平衡并翻倒时,我们就可以停止游戏。

小车代码

主要部分:控制方向,和模拟轮子向前转

写了一个检测键盘还是鼠标控制的程序在lateupdate里,主要函数:

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
void LateUpdate(){
//for all wheels
for(int i = 0; i < wheelMeshes.Length; i++){
//set the wheel mesh to the position of the wheel collider
Quaternion quat;
Vector3 pos;

wheelColliders[i].GetWorldPose(out pos, out quat);

wheelMeshes[i].position = pos;

//rotate the wheel
wheelMeshes[i].Rotate(Vector3.right * Time.deltaTime * wheelRotateSpeed);
}

if(Input.GetMouseButton(0) || Input.GetAxis("Horizontal") != 0){
UpdateTargetRotation();
}
else if(targetRotation != 0){
//rotate back to the center
targetRotation = 0;
}

//apply the rotation by rotating towards the target angle on the y axis
Vector3 rotation = new Vector3(transform.localEulerAngles.x, targetRotation, transform.localEulerAngles.z);
transform.rotation = Quaternion.RotateTowards(transform.rotation, Quaternion.Euler(rotation), rotateSpeed * Time.deltaTime);
}

void UpdateTargetRotation(){
if(Input.GetAxis("Horizontal") == 0){
//get the mouse position
if(Input.mousePosition.x > Screen.width * 0.5f){
//rotate right
targetRotation = rotationAngle;
}
else{
//rotate left
targetRotation = -rotationAngle;
}
}
else{
targetRotation = (int)(rotationAngle * Input.GetAxis("Horizontal"));
}
}

用碰撞器位置,设置轮子网格位置。

车是在原地不动的,地图向着车动,角度变化用的欧拉角

小车防止乱反转记得锁定一下轴翻转

地图生成

柏林噪声

因为地形要求平滑,随机生成点的地图上下会乱跳,这个项目里用的柏林噪声平滑曲线

柏林噪声专门针对地形生成的,可以去查一下,核心就是要能”平滑地“生成随机数。

简而言之,我们如果用一个随机数附近几个数的平均值,来替换当前这个值,那么我们得到的随机数则会变得平滑。另外还有一种做法,就是对一维白噪声进行插值,来让它变得平滑。但是,无论是滑动平均数,还是插值,得到的”平滑噪声“都有一个小缺陷:有极大可能,一些地方起伏过高,而一些地方起伏平缓。这样的“山”,依然有一种”突兀感“。怎么办呢?如果我们平滑或者插值的是噪声的“梯度”,也就是高度变化的速率,相当于给平滑的噪声先乘了一个系数,再做了一下积分,那么“突兀”的效果就再次被减小了。这就是柏林噪声的核心原理。

生成两条line直观对比一下效果

测试代码:

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
public class TestPerlin : MonoBehaviour
{
private LineRenderer _lineRenderer;
private float _a = 0.86f;
public bool UsePerLin;
// Start is called before the first frame update
void Start()
{
_lineRenderer = GetComponent<LineRenderer>();
Vector3[] posArr = new Vector3[100];
float ranx = Random.Range(1, 1000);
float rany = Random.Range(1, 1000);
for (int i = 0; i < posArr.Length; i++)
{
if (UsePerLin)
{
posArr[i] = new Vector3(i * 0.1f, Mathf.PerlinNoise(i*_a + ranx, i*_a + rany), 0);
}
else
{
posArr[i] = new Vector3(i * 0.1f, Random.value, 0);
}
}
_lineRenderer.SetPositions(posArr);
}
}

生成地形

具体代码移步文件 WorldGenerator.cs

Mesh:是指模型的网格,Mesh的主要属性内容包括顶点坐标,法线,纹理坐标,三角形绘制序列等其他有用属性和功能。因此建网格,就是画三角形;画三角形就是定位三个点。
Mesh Filter:内包含一个Mesh组件,可以根据MeshFilter获得模型网格的组件,也可以为MeshFilter设置Mesh内容。
Mesh Render:是用于把网格渲染出来的组件。MeshFilter的作用就是把Mesh扔给MeshRender将模型或者说是几何体绘制显示出来。

地图

最主要的是scale和dimensions共同决定了地形的大小和细节。Scale就是单个顶点的距离,dimensions表示构成三角形的数量。共同作用生成Mesh。比如将默认scale从0.8更改为1.6,并将x dimensions设置为18而不是32,网格将它们看起来大多一样,只是细节更少。这背后的原因是,在3d空间中增加一半的点,但将其分散到两倍的距离,从而得到相同的大小。

另一个重要值是perlin scale。可以调整这个值来调整粗糙度。地形例如,减小该值将使地形变得平滑,而
增加该值会使地形越粗糙,使得控制汽车变得更加困难。还可以设置wave height。将控制隧道内草丘的高度。较小的值使驾驶更平稳,而更高的值将使山丘更大,驾驶更困难。

然后是offset偏移值,这个主要是控制地形的形状的。每次一样的话,将会生成同样的地形。结合
randomness随机地形,这个值越大当前生成的与之前的差距就越明显,反之就小。

程序地形生成

  • 首先生成了一个三角形。只需要创建一个新的网格和在三角形数组中添加三个顶点(每个角一个)和三个索引以引用我们的顶点。
  • array(三个新角)我们可以添加另一个三角形并创建一个正方形
  • 接下来,使用两个for循环,从正方形连起来生成了一个完整的平面
  • 最后对于圆柱体,平面需要被包裹成新的形状在顶点上使用余弦和正弦

对应了最重要的那个函数 CreateShape,注释都写好了,自己看吧

移动

其实是地图移动,而不是小车移动,让地图向着小车移动,方便无尽拼接地图,主要就是一行代码:

1
transform.Translate(Vector3.forward * movespeed * Time.deltaTime);

别忘了挂上脚本,在worldGenerate代码里挂上(AddComponent)

要给灯光也挂上这个脚本,不然地形走了,灯光没跟上😂

控制镜头写在后面

无尽地形

上面生成的圆柱体只是一段,走完就没了,要达成无尽的效果,这里用的方法是,两段圆柱互相拼接的方法,走完的地图摧毁掉,重新生成拼接在后面

方法就是生成两个地形,先走一再走二,走二的时候销毁一接到二后面,写在start里

1
2
3
4
5
6
7
8
void Start(){
beginPoints = new Vector3[(int)dimensions.x + 1];

//start by generating two world pieces
for(int i = 0; i < 2; i++){
GenerateWorldPiece(i);
}
}

用一个数组收集生成的地图块,方便切换

1
2
3
4
5
6
7
8
9
void GenerateWorldPiece(int i){
//create a new cylinder and put it in the pieces array
pieces[i] = CreateCylinder();
//position the cylinder according to its index
pieces[i].transform.Translate(Vector3.forward * (dimensions.y * scale * Mathf.PI) * i);

//update this piece so it will have an endpoint and it will move etc.
UpdateSinglePiece(pieces[i]);
}

然后标记尾部位置,因为我们需要知道一个地图什么时候走完了,地形移动也挂载在这个函数下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void UpdateSinglePiece(GameObject piece){
//add the basic movement script to our newly generated piece to make it move towards the player
BasicMovement movement = piece.AddComponent<BasicMovement>();
//make it move with a speed of globalspeed
movement.movespeed = -globalSpeed;

//set the rotate speed to the lamp (directional light) rotate speed
if(lampMovement != null)
movement.rotateSpeed = lampMovement.rotateSpeed;

//create an endpoint for this piece
GameObject endPoint = new GameObject();
endPoint.transform.position = piece.transform.position + Vector3.forward * (dimensions.y * scale * Mathf.PI);
endPoint.transform.parent = piece.transform;
endPoint.name = "End Point";

//change the perlin noise offset to make sure each piece is different from the last one
offset += randomness;

//change the obstacle chance which means there will be more obstacles over time
if(startObstacleChance > 5)
startObstacleChance -= obstacleChanceAcceleration;
}

更新地形,循环拼接地图,异步更新运算,写在Lateupdate里

首先,异步地图点检测,拼接替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
IEnumerator UpdateWorldPieces(){
//remove the first piece (that is not visible to the player anymore)
Destroy(pieces[0]);

//assign the second piece to the first piece in the world array
pieces[0] = pieces[1];

//new create a new second piece
pieces[1] = CreateCylinder();

//position the new piece and rotate it to match the first piece
pieces[1].transform.position = pieces[0].transform.position + Vector3.forward * (dimensions.y * scale * Mathf.PI);
pieces[1].transform.rotation = pieces[0].transform.rotation;

//update this newly generated world piece
UpdateSinglePiece(pieces[1]);

//wait a frame
yield return 0;
}

然后实时更新地图

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
void LateUpdate(){
//if the second piece is close enough to the player, we can remove the first piece and update the terrain
if(pieces[1] && pieces[1].transform.position.z <= 0)
StartCoroutine(UpdateWorldPieces());

//update all items in the scene like spikes and gates
UpdateAllItems();
}

void UpdateAllItems(){
//find all items
GameObject[] items = GameObject.FindGameObjectsWithTag("Item");

//for all items
for(int i = 0; i < items.Length; i++){
//get all meshrenderers of this item
foreach(MeshRenderer renderer in items[i].GetComponentsInChildren<MeshRenderer>()){
//show this item if it's sufficiently close to the player
bool show = items[i].transform.position.z < showItemDistance;

if(show)
renderer.shadowCastingMode = (items[i].transform.position.y < shadowHeight) ? ShadowCastingMode.On : ShadowCastingMode.Off;

//only enable the renderer if we want to show this item
renderer.enabled = show;
}
}
}

但是仔细看的时候,会发现地图连接处有空白,两个地图能看出明显的切换

所以这里要优化一下,首先记录一下开始的点beginpoint,然后随机一点柏林噪声生成的图,y = kx+b,b随机,最后把判断点后移,让两段地图重叠一段,不要跑过了地图一立即切换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if(z < startTransitionLength && beginPoints[0] != Vector3.zero){
//if so, we must combine the perlin noise value with the begin points
//we need to increase the percentage of the vertice that comes from the perlin noise
//and decrease the percentage from the begin point
//this way it will transition from the last world piece to the new perlin noise values

//the percentage of perlin noise in the vertices will increase while we're moving further into the cylinder
float perlinPercentage = z * (1f/startTransitionLength);
//don't use the z begin point since it will not have the correct position and we only care about the noise on x and y axis
Vector3 beginPoint = new Vector3(beginPoints[x].x, beginPoints[x].y, vertices[index].z);

//combine the begin point(which are the last vertices from the previous world piece) and original vertice to smoothly transition to the new world piece
vertices[index] = (perlinPercentage * vertices[index]) + ((1f - perlinPercentage) * beginPoint);
}
else if(z == zCount){
//it these are the last vertices, update the begin points so the next piece will transition smoothly as well
beginPoints[x] = vertices[index];
}

相机跟随

首先,防止乱翻,冻结一下角度,position freeze x和z,rotation freeze y和z,车基本能正常跑了

然后编写相机跟随脚本CameraFollow

upate里监听鼠标事件

Lateupdate里更新视角,算高度和角度,用欧拉角计算,相机跟随脚本:

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
void LateUpdate(){		
//Check if the camera has a target to follow
if(!camTarget)
return;

//Some private variables for the rotation and position of the camera
float wantedRotationAngle = camTarget.eulerAngles.y;
float wantedHeight = camTarget.position.y + height;
float currentRotationAngle = transform.eulerAngles.y;
float currentHeight = transform.position.y;

currentRotationAngle = Mathf.LerpAngle(currentRotationAngle, wantedRotationAngle, rotationDamping * Time.deltaTime);

currentHeight = Mathf.Lerp(currentHeight, wantedHeight, heightDamping * Time.deltaTime);

Quaternion currentRotation = Quaternion.Euler(0, currentRotationAngle, 0);

transform.position = camTarget.position;
transform.position -= currentRotation * Vector3.forward * distance;

//Set camera postition
transform.position = new Vector3(transform.position.x, currentHeight, transform.position.z);

//Look at the camera target
transform.LookAt(camTarget);
}

添加粒子效果

增加粒子和打滑效果,把组件partical system拖上去,添加材质粒子,调整一下角度大小

在轮子底部再创建一个打滑的印记object,打滑效果脚本实现,写一个协程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
IEnumerator SkidMark(){
//loops continuesly
while(true){
//wait for the delay in between individual skid marks
yield return new WaitForSeconds(skidMarkDelay);

//show skidmarks if we need skidmarks now
if(skidMarkRoutine){
//for both rear wheels, instantiate a skid mark and parent it to the environment so it moves realistically
for(int i = 0; i < skidMarkPivots.Length; i++){
GameObject newskidMark = Instantiate(skidMark, skidMarkPivots[i].position, skidMarkPivots[i].rotation);
newskidMark.transform.parent = generator.GetWorldPiece();
newskidMark.transform.localScale = new Vector3(1, 1, 4) * skidMarkSize;
}
}
}
}

在小车添加代码,更新车轮痕迹和粒子特效,由于是物理的,要写在fixUpdate里

检测轮子是不是在地上,用Physics.Raycast射线的办法,轮子在地上再出痕迹,顺便加个力稳定一下车子

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
void UpdateEffects(){
//if both wheels are off the ground, add force will be true
bool addForce = true;
//check if we rotated the car since last frame
bool rotated = Mathf.Abs(lastRotation - transform.localEulerAngles.y) > minRotationDifference;

//for both grass effects (rear wheels)
for(int i = 0; i < 2; i++){
//get the rear wheels (one of them in each iteration)
Transform wheelMesh = wheelMeshes[i + 2];

//check if this wheel is grounded currently
if(Physics.Raycast(wheelMesh.position, Vector3.down, grassEffectOffset * 1.5f)){
//if so, show the grass effect
if(!grassEffects[i].gameObject.activeSelf)
grassEffects[i].gameObject.SetActive(true);

//update the grass effect height and the skidmark height to match this wheel
float effectHeight = wheelMesh.position.y - grassEffectOffset;
Vector3 targetPosition = new Vector3(grassEffects[i].position.x, effectHeight, wheelMesh.position.z);
grassEffects[i].position = targetPosition;
skidMarkPivots[i].position = targetPosition;

//this wheel is grounded so we don't need any extra force at the back of the car
addForce = false;
}
else if(grassEffects[i].gameObject.activeSelf){
//if we're not grounded, don't show the grass effect
grassEffects[i].gameObject.SetActive(false);
}
}

//add force at the back of the car for stabilization
if(addForce){
rb.AddForceAtPosition(back.position, Vector3.down * constantBackForce);
//don't show the skidmarks
skidMarkRoutine = false;
}
else{
if(targetRotation != 0){
//if the car has rotated show the skid mark
if(rotated && !skidMarkRoutine){
skidMarkRoutine = true;
}
else if(!rotated && skidMarkRoutine){
skidMarkRoutine = false;
}
}
else{
//don't show the skidmark if we're rotating back to the center
skidMarkRoutine = false;
}
}

//update the last rotation (which is now the current rotation since everything has been updated)
lastRotation = transform.localEulerAngles.y;
}

障碍物和门

游戏目前由两个项目组成,即钉刺障碍和玩家应该通过的大门。通过驾驶来获得积分。设置任何障碍所需的主要组件是以下内容:

  • 障碍物应具有collider
  • 障碍物应具有障碍物脚本
  • 障碍标记tag为“item

此外,请确保障碍物的起点位于底部中心,以便将其定位在正确地驶上地形

创建障碍后将其添加到 prefabs 文件夹中,并将新预制件添加到world generator中的obstacles数组中。

除了障碍物,还有gate来得分。为了得分,门有一个额外的collider,这是一个非常小的碰撞盒,is trigger选上。重要的就是请确保正确将触发器放在中心,并尽可能小,这样当我们击中门本身时就不会触发它

脚本写在WorldGenerate里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void CreateItem(Vector3 vert, int x){
//get the center of the cylinder but use the z value from the vertice
Vector3 zCenter = new Vector3(0, 0, vert.z);

//check if we get a correct angle between the center and the vertice
if(zCenter - vert == Vector3.zero || x == (int)dimensions.x/4 || x == (int)dimensions.x/4 * 3)
return;

//create a new item with a small chance of being a gate (gateChance) and a big chance of being an obstacle
GameObject newItem = Instantiate((Random.Range(0, gateChance) == 0) ? gate : obstacles[Random.Range(0, obstacles.Length)]);

//rotate the item inwards towards the center position
newItem.transform.rotation = Quaternion.LookRotation(zCenter - vert, Vector3.up);
//position the item at the vertice position
newItem.transform.position = vert;

//parent the new item to the current cylinder so it will move and rotate along
newItem.transform.SetParent(currentCylinder.transform, false);
}

最后记得调整一下障碍物和门的朝向

创建UI界面

主游戏里的界面

创建一个GameManager对象,然后创一个canvas,canvas下再创个文本,ui自己拖一下吧

文本写上分数和时间,脚本写一个穿过门的加分逻辑,外加gameover界面,自己拖一下

gameover还要停止车所有行动

用PlayerPrefs小型数据库存储得分

1
2
3
4
5
6
7
8
9
void SetScore(){
//update the highscore if our score is higher then the previous best score
if(score > PlayerPrefs.GetInt("best"))
PlayerPrefs.SetInt("best", score);

//show the score and the high score
gameOverScoreLabel.text = "score: " + score;
gameOverBestLabel.text = "best: " + PlayerPrefs.GetInt("best");
}

另在障碍物的代码里,加入撞到障碍物,就调出gamemanager的界面

结束界面和动画

创一个canvas,做结束界面,上面想要什么可以加,这里只做了最基本的

设一个动画,飞入,哈哈,附上脚本,播放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void GameOver(){
//the game cannot be over multiple times so we need to return if the game was over already
if(gameOver)
return;

//update the score and highscore
SetScore();

//show the game over animation and play the audio
gameOverAnimator.SetTrigger("Game over");
gameOverAudio.Play();

//the game is over
gameOver = true;

//break the car
car.FallApart();

//stop the world from moving or rotating
foreach(BasicMovement basicMovement in GameObject.FindObjectsOfType<BasicMovement>()){
basicMovement.movespeed = 0;
basicMovement.rotateSpeed = 0;
}
}

另添加一个小车碰到障碍物撞毁的脚本在car里,资源prefab已经有了

1
2
3
4
5
public void FallApart(){
//destroy the car
Instantiate(ragdoll, transform.position, transform.rotation);
gameObject.SetActive(false);
}

顺便,撞到门也要摧毁,包括穿过门加分,脚本写在Gate.cs里

主界面

新创一个主界面scene,作为主界面

background把地图生成代码挂上,看着好看一点

然后写脚本main menu,设置主界面跳转game的scene

用eventsystem判断是否点在ui上

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
private void Update()
{
if (Input.GetKeyDown(KeyCode.Return) ||
(Input.GetMouseButtonDown(0) && !EventSystem.current.IsPointerOverGameObject()))
{
if (!(Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began &&
EventSystem.current.IsPointerOverGameObject((Input.GetTouch(0).fingerId))))
{
StartGame();
}
}
}

public void StartGame()
{
UIAnimator.SetTrigger("Start");
StartCoroutine(LoadScene("Game"));
}

IEnumerator LoadScene(string scene)
{
yield return new WaitForSeconds(0.6f);

SceneManager.LoadScene(scene);
}

再简单写个music脚本,持续循环播放,挂上!结束!完结撒花🎉

第一次写没有教程的项目,可能看着有点乱,脚本思路也是想到哪写到哪,下次注意!