Unity Demo-3DRaceCar详解

发布于:2025-06-16 ⋅ 阅读:(20) ⋅ 点赞:(0)

今天我来拆解一个3D赛车游戏的Demo,看看实现一个赛车游戏需要哪些内容。

主要的技术难点包括:

如何实现无限的赛道?

如何基于柏林噪声实现地形?

如何创造圆柱形的赛道?

如何实现道具效果?

首先各种游戏内物体的素材就靠各位自己去找了,我的素材如图所示:

这是一个简单的四轮小车,可以看到比较特殊的就是这是多个碰撞体的结合:一个立方体碰撞体加上四个轮毂碰撞体(用胶囊碰撞体实现)。

除此之外我们还有作为路障的物体以及用于加分的物体:

我们的设计很简单:小车遇到路障或者门的门框(而不是通过的话)就散架(是的,散架),否则如果穿过门就加分。

那么首先我们有一个路障的代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Obstacle : MonoBehaviour {
    
    // 游戏管理器的引用
    GameManager manager;
	
    void Start(){
        // 查找场景中的游戏管理器
        manager = GameObject.FindObjectOfType<GameManager>();
        
        // 确保障碍物拥有"Obstacle"标签
        if (!gameObject.CompareTag("Obstacle"))
        {
            gameObject.tag = "Obstacle";
            Debug.Log($"[Obstacle] 障碍物标签已设置为Obstacle: {gameObject.name}");
        }
    }
	
    // 碰撞逻辑已移除,由Car.cs负责处理所有碰撞
}

我们初始化游戏管理器,然后强迫挂载Obstacle的脚本的Tag修改为"Obstacle"。

门也得挂载这个脚本,但同时门还要负责加分,所以还有另一个脚本。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Gate : MonoBehaviour {
	
    // 在 Inspector 中可见的变量(用于音效播放)
    public AudioSource scoreAudio;  // 得分时播放的音效
	
    // 在 Inspector 中不可见的变量
    GameManager manager;  // 游戏管理器引用
    bool addedScore;  // 标记是否已经得分
	
    void Start(){
        // 查找游戏管理器
        manager = GameObject.FindObjectOfType<GameManager>();
    }
	
    void OnTriggerEnter(Collider other){
        // 检查玩家是否通过了这个关卡门并且还没有得分
        if(!other.gameObject.transform.root.CompareTag("Player") || addedScore)
            return;  // 如果碰撞物体不是玩家,或已经得分,则不执行
		
        // 增加分数并播放音效
        addedScore = true;  // 标记已得分
        manager.UpdateScore(1);  // 更新分数
        scoreAudio.Play();  // 播放得分音效
    }
}

大体的内容注释也写得很清楚了,这个门首先获取到GameManager和AudioSource的实例,然后使用Unity自带的OnTriggerEnter函数在标签为Player的物体且这个门还没有执行过加分发生碰撞检测时执行加分。

然后是我们的一些道具:

首先我们有一个总的道具类,然后多个道具都继承自这个道具类(类似工厂模式)

public abstract class PowerUp : MonoBehaviour 
{
    public GameObject visualEffect;  // 道具的视觉效果
    public AudioClip collectSound;  // 收集音效
    public GameObject collectEffect;  // 收集特效
    
    protected Car playerCar;  // 玩家车辆引用
    protected GameManager gameManager;  // 游戏管理器引用
    
    protected virtual void Start()
    {
        playerCar = FindObjectOfType<Car>();
    }
    
    protected virtual void Update()
    {
        // 只检测玩家是否靠近,靠近就触发
        if (playerCar != null)
        {
            float distance = Vector3.Distance(transform.position, playerCar.transform.position);
            if (distance < 2f)
            {
                OnTriggerEnterManual(playerCar.gameObject);
            }
        }
    }
    
    protected virtual void OnTriggerEnterManual(GameObject other)
    {
        if (other.CompareTag("Player"))
        {
            ProcessPlayerCollision();
        }
    }
    
    protected virtual void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            ProcessPlayerCollision();
        }
    }
    
    protected virtual void ProcessPlayerCollision()
    {
        // 播放收集音效
        if (collectSound != null)
        {
            AudioSource.PlayClipAtPoint(collectSound, transform.position);
        }
        // 生成收集特效
        if (collectEffect != null)
        {
            Instantiate(collectEffect, transform.position, Quaternion.identity);
        }
        // 激活道具效果
        ActivatePowerUp();
        // 立即销毁道具对象
        Destroy(gameObject);
    }
    
    // 道具效果激活
    protected abstract void ActivatePowerUp();
    // 道具效果结束(不再需要)
} 

首先这是一个抽象类:关键字abstarct,且全部由虚函数构成。在Update中获取玩家操控的Car类的实例,然后在Update中进行距离判断,然后是两个Trigger相关的函数:OnTriggerEnterManual和OnTriggerEnter用来判断发生Trigger的碰撞时另一个碰撞体的标签是否是"Player",是的话就执行道具效果函数ProcessPlayerCollision:具体内容包括播放音效和视觉特效并执行ActivatePowerUp函数之后销毁道具——ActivatePowerUp函数则是一个纯虚函数,要求所有继承PowerUp类的脚本必须实现。

香蕉皮,没有美术素材就拿个黄色球意思一下。 

    protected override void ActivatePowerUp()
    {
        if (playerCar != null)
        {
            playerCar.ApplyBananaSlip(slipForce, rotationForce);
        }
        Debug.Log("[Banana] 触发香蕉皮打滑效果");
    }

香蕉皮的ActivatePowerUp函数如图,执行一个Car类中写好的香蕉皮打滑效果。

	public void ApplyBananaSlip(float slipForce, float rotationForce)
	{
		// 忽略参数值,直接启动一个协程来处理香蕉皮效果
		StartCoroutine(DirectBananaEffect());
	}

执行相关协程:
 

private IEnumerator DirectBananaEffect()
	{
		// 只记录原始旋转
		Quaternion originalRotation = transform.rotation;
		
		// 晃动持续时间和强度
		float duration = 2.0f;
		float intensity = 30f;  // 增加强度
		float elapsed = 0f;
		
		Debug.Log("[Car] 香蕉皮效果: 开始剧烈旋转!");
		
		// 晃动循环
		while (elapsed < duration)
		{
			// 计算当前强度 (波浪式变化,不是线性衰减)
			float wave = Mathf.Sin((elapsed / duration) * Mathf.PI * 6);  // 创造波浪效果
			float currentIntensity = intensity * Mathf.Abs(wave);
			
			// 随机旋转量,主要在Y轴和Z轴
			float rotX = Random.Range(-1f, 1f) * currentIntensity * 0.4f;  // X轴轻微旋转
			float rotY = Random.Range(-2f, 2f) * currentIntensity;  // Y轴强烈旋转
			float rotZ = Random.Range(-1f, 1f) * currentIntensity * 0.7f;  // Z轴中等旋转
			
			// 应用旋转 - 直接修改旋转而不是累积
			transform.rotation = originalRotation * Quaternion.Euler(rotX, rotY, rotZ);
			
			// 更新时间
			elapsed += Time.deltaTime;
			yield return null;
		}
		
		// 恢复原始旋转
		transform.rotation = originalRotation;
		
		Debug.Log("[Car] 香蕉皮效果: 旋转结束!");
	}

可以看到我们首先记录原始的旋转(用一个四元数来记录以避免万向锁和提高计算效率),然后在计时器小于设定的道具效果时间时我们去根据数学函数生成随机的一个不同轴的波动强度给到小车的旋转以实现香蕉皮打滑的效果,最后再还原到之前的旋转。

加速球,用于加速。

using UnityEngine;
using System.Collections;

public class SpeedBoostPowerUp : PowerUp 
{
    public float speedMultiplier = 2.0f;  // 速度提升倍数

    private WorldGenerator worldGenerator;  // 世界生成器引用

    
    protected override void Start()
    {
        base.Start();
        
        // 获取WorldGenerator引用
        worldGenerator = FindObjectOfType<WorldGenerator>();
        if (worldGenerator == null)
        {
            Debug.LogError("[SpeedBoost] 严重错误: 未找到WorldGenerator!");
        }
        
        Debug.Log($"[SpeedBoost] 初始化完成,速度倍数: {speedMultiplier}");
    }
    
    protected override void ActivatePowerUp()
    {
        if (playerCar != null)
        {
            playerCar.StartSpeedBoost(5f, speedMultiplier); // 5秒加速,可根据需要调整
        }
        Debug.Log("[SpeedBoost] 触发加速效果");
    }
}

 与香蕉皮类似,我们也去调用car类里写好的加速函数,然后需要注意的是我们实现这个效果比如要一个世界生成器类WorldGenerator类,因为这个项目比较特殊:我们的赛道是一个无限生成的赛道,而这个赛道其实本质上是由两个不同的世界片段不断地交替更新实现的。在我们的加速过程中,其实真正加速的不是赛车而是我们小车行驶的地面——想象一下跑步机就好了,真正加速的是我们的跑步机而不是我们的人,我们人的绝对位置其实是没有移动的。

private Coroutine speedBoostCoroutine;
...

public void StartSpeedBoost(float duration, float speedMultiplier)
{
	if (speedBoostCoroutine != null)
	{
		StopCoroutine(speedBoostCoroutine);
		EndSpeedBoost();
	}
	speedBoostCoroutine = StartCoroutine(SpeedBoostRoutine(duration, speedMultiplier));
}

检测是否曾通过该变量启动过协程​,如果启动过就强制终止该协程,这样设计的意义是:确保同一时间只有一个速度提升效果在运行,新效果会强制中断并覆盖旧效果

	private IEnumerator SpeedBoostRoutine(float duration, float speedMultiplier)
	{
		isSpeedBoosting = true;
		WorldGenerator generator = FindObjectOfType<WorldGenerator>();
		if (generator != null)
		{
			originalGlobalSpeed = generator.globalSpeed;
			float newSpeed = originalGlobalSpeed * speedMultiplier;
			generator.SetGlobalSpeed(newSpeed);
			Debug.Log($"[Car] SpeedBoost: 世界速度提升到 {newSpeed}");
		}
		yield return new WaitForSeconds(duration);
		EndSpeedBoost();
	}

	private void EndSpeedBoost()
	{
		isSpeedBoosting = false;
		WorldGenerator generator = FindObjectOfType<WorldGenerator>();
		if (generator != null)
		{
			generator.SetGlobalSpeed(originalGlobalSpeed);
			Debug.Log($"[Car] SpeedBoost: 世界速度恢复到 {originalGlobalSpeed}");
		}
	}

可以看到,我们会去修改WorldGenerator的globalSpeed来实现加速的效果,加速的协程通过yield return new WaitForSeconds(duration)来控制时长。

护盾道具,给予小车短暂的无敌时间。

using UnityEngine;
using System.Collections;

public class ShieldPowerUp : PowerUp
{
    private Car car;

    protected override void Start()
    {
        base.Start();
        car = FindObjectOfType<Car>();
    }

    protected override void ActivatePowerUp()
    {
        if (car != null)
        {
            car.StartInvincible(5f); // 5秒无敌,可根据需要调整
            Debug.Log("[护盾] 无敌模式已开启");
            // 不再创建任何球体或视觉效果
        }
    }
} 

调用Car类中的无敌方法:

	public void StartInvincible(float duration)
	{
		if (invincibleCoroutine != null)
		{
			StopCoroutine(invincibleCoroutine);
			EndInvincible();
		}
		invincibleCoroutine = StartCoroutine(InvincibleRoutine(duration));

		// 开启护盾视觉特效
		if (shieldEffect != null)
			shieldEffect.SetActive(true);
		if (statusIndicator != null)
			statusIndicator.SetActive(true);
	}

类似于之前加速的协程的设计,保证同一时刻不会有两个道具效果的叠加。

	private IEnumerator InvincibleRoutine(float duration)
	{
		isInvincible = true;
		Debug.Log($"[Car] Invincible: 无敌模式开启,持续{duration}秒");
		yield return new WaitForSeconds(duration);
		EndInvincible();
	}

	public void EndInvincible()
	{
		isInvincible = false;
		Debug.Log("[Car] Invincible: 无敌模式关闭");
		// 关闭护盾视觉特效
		if (shieldEffect != null)
			shieldEffect.SetActive(false);
		if (statusIndicator != null)
			statusIndicator.SetActive(false);
	}

可以看到在协程中我们通过修改bool变量isInvincible来开启或关闭无敌效果,这个无敌模式我们通过这样实现:

	void OnCollisionEnter(Collision collision)
	{
		if (collision.gameObject.CompareTag("Obstacle"))
		{
			Debug.Log($"[车辆] 碰撞到障碍物: {collision.gameObject.name}");
			
			if (invincibleMode || isInvincible)
			{
				Debug.Log("[车辆] 处于无敌模式,销毁障碍物");
				Destroy(collision.gameObject);  // 销毁障碍物
				return;
			}
			
			Debug.Log("[车辆] 没有任何保护,车辆将散架");
			FallApart();
		}
	}

当我们的车辆遇到障碍物时,如果我们处于障碍物时直接去销毁路障,否则我们就执行FallApart函数直接散架。

这样大体上我们的道具就实现了,现在我们来看看世界生成器WorldGenerator是如何实现的:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class WorldGenerator : MonoBehaviour
{

    //一系列变量

    void Start()
    {
        // 创建数组,用于存储每个世界部分的起始顶点(用于正确过渡)

        // 先生成两个世界部分
    }

    void LateUpdate()
    {
        // 如果第二个部分已经接近玩家,移除第一个部分并更新世界

        // 更新场景中的所有物品,如障碍物和大门
    }

    void UpdateAllItems()
    {
        // 同时查找所有带有 "Item" 和 "Obstacle" 标签的物品


        // 处理所有"Item"标签的物品


        // 处理所有"Obstacle"标签的物品

    }

    // 处理物体的MeshRenderer组件
    private void ProcessRenderers(GameObject[] objects)
    {
       
    }
    //生成世界片段
    void GenerateWorldPiece(int i)
    {
        
    }

    IEnumerator UpdateWorldPieces()
    {
       
    }
    //更新世界分块
    void UpdateSinglePiece(GameObject piece)
    {

    }
    //创造圆柱形的赛道
    public GameObject CreateCylinder()
    {

    }

    // 生成并返回新世界部分的网格
    Mesh Generate()
    {
        
    }
    //生成地形
    void CreateShape(ref Vector3[] vertices, ref Vector2[] uvs, ref int[] triangles)
    {

    }
    生成所有赛道内物体(包括障碍物和道具)
    void CreateItem(Vector3 vert, int x)
    {

    }

    // 生成道具的方法
    private GameObject CreatePowerUp(Vector3 position, Transform parent)
    {
        
    }

    // 道具清理方法
    void ClearPowerUpsOnPiece(GameObject piece)
    {

    }

    // 设置全局速度并更新所有地形片段的速度
    public void SetGlobalSpeed(float newSpeed)
    {

    }
    // 返回第一个世界部分的 Transform
    public Transform GetWorldPiece()
    {
        
    }

    // Destory函数
    void OnDestroy()
    {

    }

}

可以看到非常多的内容,我们一点一点来介绍。

   void Start()
   {
       // 创建数组,用于存储每个世界部分的起始顶点(用于正确过渡)
       beginPoints = new Vector3[(int)dimensions.x + 1];

       // 先生成两个世界部分
       for (int i = 0; i < 2; i++)
       {
           GenerateWorldPiece(i);
       }
   }
    void LateUpdate()
    {
        // 如果第二个部分已经接近玩家,移除第一个部分并更新世界
        if (pieces[1] && pieces[1].transform.position.z <= 0)
            StartCoroutine(UpdateWorldPieces());

        // 更新场景中的所有物品,如障碍物和大门
        UpdateAllItems();
    }

这里可以看到的一个细节是,我们要在LateUpdate中执行更新世界片段和物体的操作而不是在Update中,这是因为我们的其他脚本中可能涉及到位置的变换等等,我们必须要等待其他相关脚本中的Update执行完之后再去计算距离来更新物品和世界片段。

    void UpdateAllItems()
    {
        // 同时查找所有带有 "Item" 和 "Obstacle" 标签的物品
        GameObject[] items = GameObject.FindGameObjectsWithTag("Item");
        GameObject[] obstacles = GameObject.FindGameObjectsWithTag("Obstacle");

        // 处理所有"Item"标签的物品
        ProcessRenderers(items);

        // 处理所有"Obstacle"标签的物品
        ProcessRenderers(obstacles);
    }

    // 处理物体的MeshRenderer组件
    private void ProcessRenderers(GameObject[] objects)
    {
        for (int i = 0; i < objects.Length; i++)
        {
            // 获取物品的所有 MeshRenderer
            foreach (MeshRenderer renderer in objects[i].GetComponentsInChildren<MeshRenderer>())
            {
                // 如果物品距离玩家足够近,则显示该物品
                bool show = objects[i].transform.position.z < showItemDistance;

                // 如果需要显示物品,更新其阴影投射模式
                // 由于世界是圆柱形的,只有底半部分的物体需要阴影
                if (show)
                    renderer.shadowCastingMode = (objects[i].transform.position.y < shadowHeight) ? ShadowCastingMode.On : ShadowCastingMode.Off;

                // 只有在需要显示物品时才启用其渲染器
                renderer.enabled = show;
            }
        }
    }

UpdateAllItems则是去游戏场景中找到所有带有Item和Obstacle标签的物体执行ProcessRenderers函数:这个函数的作用就是去获取物体的MeshRenderer并判断该GameObject类物体与设定的showItemDistance是否更小,更小则渲染且再判断该物体是否位于圆柱底部,位于底部则渲染影子。

关于MeshRenderer的shadowCastingMode:

    void GenerateWorldPiece(int i)
    {
        pieces[i] = CreateCylinder();
        pieces[i].transform.Translate(Vector3.forward * (dimensions.y * scale * Mathf.PI) * i);
        UpdateSinglePiece(pieces[i]);
        Debug.Log($"[WorldGen] 生成片段 {i},位置z={pieces[i].transform.position.z}");
    }

生成世界片段的函数,可以看到输入参数是一个序号,表示当前世界片段的序号,先创造一个圆柱体,然后把这个圆柱体的transform移动到当前y轴乘以scale和pai最后再乘以序号,方向是正前方向,不难看出这个是我们的周长公式,而为什么要把新的圆柱生成在周长处呢?dimension的y其实对应的就是我们Unity坐标系中的Z轴,scale是不同顶点的间隔,所以我们其实就是在拿z轴的顶点数再乘以顶点间隔之后再乘以pai,至于这个pai只是我们在生成顶点时就乘以的系数,并没有什么额外的几何意义。

    IEnumerator UpdateWorldPieces()
    {
        Debug.Log($"[WorldGen] 触发片段更新,销毁片段0,片段1位置z={pieces[1].transform.position.z},startObstacleChance={startObstacleChance}");
        ClearPowerUpsOnPiece(pieces[0]);
        Destroy(pieces[0]);
        pieces[0] = pieces[1];
        pieces[1] = CreateCylinder();
        pieces[1].transform.position = pieces[0].transform.position + Vector3.forward * (dimensions.y * scale * Mathf.PI);
        pieces[1].transform.rotation = pieces[0].transform.rotation;
        UpdateSinglePiece(pieces[1]);
        Debug.Log($"[WorldGen] 新片段生成完成,片段1位置z={pieces[1].transform.position.z},dimensions={dimensions},scale={scale}");
        yield return 0;
    }

这是我们更新世界片段的协程,可以看到我们其实只有两个世界片段,我们销毁第一个世界片段并把第二个世界片段换到第一个世界片段并重新生成圆柱,position和rotation进行更新。

    void UpdateSinglePiece(GameObject piece)
    {
        // 给新生成的部分添加基本运动脚本,使其朝向玩家移动
        BasicMovement movement = piece.AddComponent<BasicMovement>();
        // 设置其移动速度为 globalSpeed(负数表示朝玩家方向移动)
        movement.movespeed = -globalSpeed;

        // 设置旋转速度为灯光(方向光)的旋转速度
        if (lampMovement != null)
            movement.rotateSpeed = lampMovement.rotateSpeed;

        // 为此部分创建一个终点
        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";

        // 改变 Perlin 噪声的偏移量,以确保每个世界部分与上一个不同
        offset += randomness;
    }

我们给新的世界片段手动添加新的脚本(组件,Unity中的脚本就是组件)BasicMovement,让这个世界片段也能移动,然后根据前向偏移量提前设置好终点,最后修改柏林噪声相关偏移量以保证不同世界部分的柏林噪声不同。

    public GameObject CreateCylinder()
    {
        // 创建世界部分的基础对象并命名
        GameObject newCylinder = new GameObject();
        newCylinder.name = "World piece";

        // 设置当前圆柱体为新创建的对象
        currentCylinder = newCylinder;

        // 给新部分添加 MeshFilter 和 MeshRenderer 组件
        MeshFilter meshFilter = newCylinder.AddComponent<MeshFilter>();
        MeshRenderer meshRenderer = newCylinder.AddComponent<MeshRenderer>();

        // 给新部分设置材质
        meshRenderer.material = meshMaterial;
        // 生成网格并赋值给 MeshFilter
        meshFilter.mesh = Generate();

        // 添加与网格匹配的 MeshCollider 组件
        newCylinder.AddComponent<MeshCollider>();

        return newCylinder;
    }

这是创建新的圆柱形相关的代码,我们生成一个新的GameObject并添加MeshFilter和MeshRenderer之后去更改部分组件内容之后返回。

在Unity中,MeshFilter和MeshRenderer是渲染3D模型的核心组件,二者协同工作以实现模型的几何形状定义与可视化渲染:

比较简单地说,MeshFilter通过自定义的方式定义网格属性之后把网格传给MeshRenderer——后者负责根据材质实现渲染。

可以看到给到MeshFilter的Mesh是由Generate函数生成的:

    Mesh Generate()
    {
        // 创建并命名新网格
        Mesh mesh = new Mesh();
        mesh.name = "MESH";

        // 创建数组来存储顶点、UV 坐标和三角形
        Vector3[] vertices = null;
        Vector2[] uvs = null;
        int[] triangles = null;

        // 创建网格形状并填充数组
        CreateShape(ref vertices, ref uvs, ref triangles);

        // 给网格赋值
        mesh.vertices = vertices;
        mesh.uv = uvs;
        mesh.triangles = triangles;

        // 重新计算法线
        mesh.RecalculateNormals();

        return mesh;
    }

这其中的核心部分显然是这个CreateShape:

void CreateShape(ref Vector3[] vertices, ref Vector2[] uvs, ref int[] triangles)
    {
        // 获取该部分在 x 和 z 轴的大小
        int xCount = (int)dimensions.x;  // 在 x 轴上分割的顶点数量
        int zCount = (int)dimensions.y;  // 在 z 轴上分割的顶点数量

        // 初始化顶点和 UV 数组
        vertices = new Vector3[(xCount + 1) * (zCount + 1)];
        uvs = new Vector2[(xCount + 1) * (zCount + 1)];

        int index = 0;

        // 获取圆柱体的半径
        float radius = xCount * scale * 0.5f;  // 圆柱的半径

        // 双重循环遍历 x 和 z 轴的所有顶点
        for (int x = 0; x <= xCount; x++)
        {
            for (int z = 0; z <= zCount; z++)
            {
                // 获取圆柱体的角度,以正确设置顶点位置
                float angle = x * Mathf.PI * 2f / xCount;

                // 使用角度的余弦和正弦值来设置顶点
                vertices[index] = new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, z * scale * Mathf.PI);

                // 更新 UV 坐标
                uvs[index] = new Vector2(x * scale, z * scale);

                // 使用 Perlin 噪声生成 X 和 Z 值
                float pX = (vertices[index].x * perlinScale) + offset;
                float pZ = (vertices[index].z * perlinScale) + offset;

                // 将顶点移动到中心位置(保持 z 坐标)并使用 Perlin 噪声调整位置
                Vector3 center = new Vector3(0, 0, vertices[index].z);
                vertices[index] += (center - vertices[index]).normalized * Mathf.PerlinNoise(pX, pZ) * waveHeight;

                // 处理世界部分之间的平滑过渡
                if (z < startTransitionLength && beginPoints[0] != Vector3.zero)
                {
                    // 如果是过渡部分,结合 Perlin 噪声和上一个部分的起始点
                    float perlinPercentage = z * (1f / startTransitionLength);
                    Vector3 beginPoint = new Vector3(beginPoints[x].x, beginPoints[x].y, vertices[index].z);
                    vertices[index] = (perlinPercentage * vertices[index]) + ((1f - perlinPercentage) * beginPoint);
                }
                else if (z == zCount)
                {
                    // 更新起始点,以确保下一部分的平滑过渡
                    beginPoints[x] = vertices[index];
                }

                if (z % 10 == 0) // 每10个单位记录一次,避免日志过多
                {
                    // Debug.Log($"[WorldGen] 当前位置 x={x}, z={z}, 实际z={vertices[index].z}, startObstacleChance={startObstacleChance}");
                }

                if (Random.Range(0, startObstacleChance) == 0 && !(gate == null && obstacles.Length == 0))
                {
                    //Debug.Log($"[WorldGen] CreateShape 生成障碍物判定通过,z={vertices[index].z}, startObstacleChance={startObstacleChance}");
                    CreateItem(vertices[index], x);
                }

                // 增加顶点索引
                index++;
            }
        }

        // 初始化三角形数组
        triangles = new int[xCount * zCount * 6];  // 每个方格有 2 个三角形,每个三角形由 3 个顶点组成,共 6 个顶点

        // 创建每个方块的基础(三角形的组成更简单)
        int[] boxBase = new int[6];  // 每个正方形面由 6 个顶点组成(两个三角形)

        int current = 0;

        // 遍历 x 轴上的所有位置
        for (int x = 0; x < xCount; x++)
        {
            boxBase = new int[]{
                x * (zCount + 1),
                x * (zCount + 1) + 1,
                (x + 1) * (zCount + 1),
                x * (zCount + 1) + 1,
                (x + 1) * (zCount + 1) + 1,
                (x + 1) * (zCount + 1),
            };

            // 遍历 z 轴上的所有位置
            for (int z = 0; z < zCount; z++)
            {
                // 增加顶点索引并创建三角形
                for (int i = 0; i < 6; i++)
                {
                    boxBase[i] = boxBase[i] + 1;
                }

                // 使用六个顶点填充三角形
                for (int j = 0; j < 6; j++)
                {
                    triangles[current + j] = boxBase[j] - 1;
                }

                // 增加当前索引
                current += 6;
            }
        }
    }

这个就是我们基于柏林噪声生成网格的核心代码了,首先根据输入的网格密度参数(xCount为圆周方向分段数,zCount为隧道长度分段数)计算顶点坐标——利用三角函数(Mathf.Cos(angle) * radiusMathf.Sin(angle) * radius)将顶点沿圆周分布,并通过z * scale * Mathf.PI沿Z轴延伸形成螺旋结构,同时为每个顶点生成UV坐标以支持纹理映射;随后通过Perlin噪声​(Mathf.PerlinNoise(pX, pZ) * waveHeight)对顶点位置施加随机扰动,模拟隧道表面的自然凹凸,并采用平滑过渡机制——当顶点位于起始过渡区(z < startTransitionLength)时,与上一片段记录的端点(beginPoints[x])进行插值混合以消除接缝,并在片段末端(z == zCount)更新beginPoints供后续片段衔接使用;此外,在特定位置(如z % 10 == 0)按概率startObstacleChance生成障碍物(调用CreateItem),增加场景交互性;最后通过双重循环构建三角形索引数组,将每4个顶点拆分为2个三角形(6个索引),以boxBase模板填充索引数据,形成连续的三维曲面。

可能看起来有些复杂,我们可以先想象我们有一个平摊的网格,网格由一堆顶点组成——顶点的个数由相关的网格密度参数xCount和zCount决定,x轴对应圆柱体周长而z轴对应圆柱体长度。我们现在像卷大饼一样把这个平摊的网格乘以一个三角函数来将这个网格平面变成一个圆柱体,然后我们将这个卷起来的卷饼内部的不同顶点根据柏林噪声改变顶点位置,最后加上我们的MeshColllider即可。

    void CreateItem(Vector3 vert, int x)
    {
        Debug.Log($"[WorldGen] CreateItem 被调用,z={vert.z}");
        // 获取圆柱体的中心位置,但使用顶点的 z 坐标
        Vector3 zCenter = new Vector3(0, 0, vert.z);

        // 检查生成物品的正确位置,优化判断条件
        if (zCenter - vert == Vector3.zero ||
           (x == (int)dimensions.x / 4 && Mathf.Abs(vert.y) < 0.1f) ||
           (x == (int)dimensions.x / 4 * 3 && Mathf.Abs(vert.y) < 0.1f))
        {
            // Debug.Log($"[WorldGen] 跳过物品生成 - zCenter-vert: {zCenter-vert}, x: {x}, dimensions.x/4: {(int)dimensions.x/4}");
            return;
        }

        GameObject newItem = null;

        // 调整道具生成逻辑,确保更均匀的分布
        bool shouldCreatePowerUp = Random.Range(0, 100) < powerUpChance &&
                                 powerUps != null &&
                                 powerUps.Length > 0;

        if (shouldCreatePowerUp)
        {
            newItem = CreatePowerUp(vert, currentCylinder.transform);
            if (newItem == null)
            {
                shouldCreatePowerUp = false;
            }
        }

        if (!shouldCreatePowerUp)
        {
            bool isGate = (Random.Range(0, gateChance) == 0);
            // if (isGate)
            //     Debug.Log("[WorldGen] 生成大门");
            // else
            //     Debug.Log("[WorldGen] 生成障碍物");
            newItem = Instantiate(isGate ? gate : obstacles[Random.Range(0, obstacles.Length)]);
            newItem.transform.rotation = Quaternion.LookRotation(zCenter - vert, Vector3.up);
            newItem.transform.position = vert;
            newItem.transform.SetParent(currentCylinder.transform, false);
        }
    }

    // 生成道具的方法
    private GameObject CreatePowerUp(Vector3 position, Transform parent)
    {
        if (powerUps == null || powerUps.Length == 0) return null;

        // 检查是否与上一个道具距离太近
        if (Mathf.Abs(position.z - lastPowerUpZ) < minPowerUpSpacing)
        {
            return null;
        }

        // 随机选择一个道具预制体
        GameObject powerUpPrefab = powerUps[Random.Range(0, powerUps.Length)];

        // 计算道具应该放置的正确高度
        // 使用顶点的位置并添加一个固定高度,这样道具就不会嵌入地形或悬在空中
        Vector3 spawnPosition = position;
        // 稍微提高道具的位置,使其位于地形上方
        spawnPosition.y += powerUpHeight;

        // 实例化道具
        GameObject powerUp = Instantiate(powerUpPrefab, spawnPosition, Quaternion.identity);

        // 设置父物体
        powerUp.transform.SetParent(parent);

        // 添加到活动道具列表
        activePowerUps.Add(powerUp);

        // 更新最后一个道具的Z坐标
        lastPowerUpZ = (int)position.z;

        return powerUp;
    }

    // 修改清理方法,确保道具被正确销毁前不触发效果
    void ClearPowerUpsOnPiece(GameObject piece)
    {
        List<GameObject> powerUpsToRemove = new List<GameObject>();

        foreach (GameObject powerUp in activePowerUps)
        {
            if (powerUp == null || (powerUp.transform.parent != null && powerUp.transform.parent.gameObject == piece))
            {
                powerUpsToRemove.Add(powerUp);
                if (powerUp != null)
                {
                    // 直接销毁道具对象,不再调用MarkAsCleared
                    Destroy(powerUp);
                }
            }
        }

        foreach (GameObject powerUp in powerUpsToRemove)
        {
            activePowerUps.Remove(powerUp);
        }
    }

最后是道具生成的方法,注释写得比较详细,这几段代码主要负责在圆柱形赛道上生成和管理游戏物品(包括障碍物、大门和道具)。CreateItem方法负责在合适的位置生成物品,它会检查生成位置是否合适,并根据概率决定生成道具还是障碍物,同时确保物品朝向正确;CreatePowerUp方法专门处理道具的生成,它会检查道具之间的间距,设置合适的高度,并管理道具的生命周期;而ClearPowerUpsOnPiece方法则负责在不需要时安全地清理特定世界片段上的所有道具。这些代码共同确保了赛道上物品的合理分布、正确朝向和及时清理,为玩家提供流畅的游戏体验。

至此我们这个项目的大多数内容就讲解完了,让我们看看最开始的几个问题如何解答:

如何实现无限的赛道?

答:我们其实是基于类似于内存池的方式来做的:真正的世界片段只有两个,但是我们通过每生成一个新世界片段时就通过可以计算的前向偏移得到终点后在LateUpdate中检测距离,当距离小于一定值时就生成新的圆柱形世界片段并更新世界片段即可。

如何基于柏林噪声实现地形?

答:柏林噪声本身是Unity自带的内容,我们的赛道本质上是一个更改了顶点位置的网格,我们通过柏林噪声去修改顶点的具体位置即可实现波浪状的地形。

如何创造圆柱形的赛道?

答:这个和我们的程序生成地形有关,我们程序化地形的过程是先生成一定顶点数的网格然后通过三角函数沿着X轴修改顶点位置以实现圆柱形网格。

如何实现道具效果?

答:因为本质上我们的赛车游戏中是世界片段在移动而不是小车在移动,所以主要是加速部分需要去更改世界片段的速度,其他的道具效果都是直接基于小车的属性进行更改,比较简单。

但这里又引出一个问题就是:为何我们让世界片段移动而不是让小车移动?

这个项目选择让世界片段移动而不是让小车移动,主要是出于以下几个考虑:首先,在无限赛道的设计中,如果让小车移动,需要不断生成新的赛道片段并销毁旧的片段,这样会导致频繁的内存分配和释放,影响性能;其次,让世界片段移动可以保持小车始终在视野中心,这样更容易控制视角和实现特效(如滑痕、草地特效等);第三,这种设计使得赛道的生成和销毁更加可控,可以精确地控制新片段的生成时机和位置,确保赛道的连续性和平滑性;最后,这种设计也简化了物理模拟,因为只需要处理相对运动,而不需要考虑小车高速移动时可能带来的物理计算问题。

这个回答是AI生成的,孰对孰错大家自行判断吧。


网站公告

今日签到

点亮在社区的每一天
去签到