Loading... 这是博主的一篇Unity的学习笔记,教程来自这个视频:[【Unity教程】从0编程制作类吸血鬼幸存者游戏](https://www.bilibili.com/video/BV1Sz4y1p7Tu) 程序和代码的下载链接附在了文章底部,希望能够帮助到和博主相同的Unity初学者。 ## 3. 设置和导入 开始项目之前先更改一些设置,把游戏分辨率调成Full HD「1920*1080」,然后在软件编辑器中去除掉一些我们用不到的包——JetBrains Rider Editor和Visaual Scripting。 ## 4. 创建场景 点击file→New Scene选项,创建一个basic 2D (built in) 场景,点击Crtl+S键保存,命名为Main,保存在 `../Assets/Scenes`文件夹下。 可以看到Main Camera的Projection为orthographic「自由视角」,更改其旁的Size设置摄像头的大小为8.5。 使用Unity中的tiles map「瓦片地板」在游戏中添加一些地板。在场景Main中右键,创建一个2D object的Tilemap,形状选择rectangular「矩形」,虽然是矩形但默认情况下地图都是基于正方形地板,可以在右边的选项栏中调整地板的大小。之后点击window→2D→tile palette选项,把他拖到右边的栏中,点击Create New Palette创建一个新的调色板并命名为Main Palette,保存在 `../Assets/Tiles/Main`文件夹下。然后把Assets里的的精灵图直接拖到右边的Tile Palette中,这时它会让我们选择瓦片的储存位置,我们选择刚刚创建的Main文件夹,之后就可以像画图软件一样,选择我们要画的地板在场景中拖动。但是这样一格格手动绘制的方法太过繁琐。更快速的方法是切换到盒子模具,在图上拖动就会快速产生一大片地板。 **Tips:每次更改后最好都Ctrl+S保存一下,以免丢失数据,当有未保存的改动时,场景名称上会有一个小星号。** ## 5. 添加玩家 在场景Main下右键,点击 Create Empty,重命名为Player。点击精灵图旁的播放按钮把所有的图像列出来,选中小人的图像拖到场景中。这时你可能看不见小人,因为小人和草现在处在同一平面上,导致小人和草地显示的优先级是随机的。我们先把小人放到Player的子项下,重命名为Sprite,在右侧的选项栏中找到Sprite Renderer→Additional Settings→Sorting Layer/Order in Layer来改变图层的位置,**层的数字越高,显示的优先级越高**。 可以把小人的Order in Layer从0设置为1,这样小人就会出现在草地的前面。 更好的方法是点击Sorting Layer下的Add a Sorting Layer,添加两个图层,Layer1重命名为BG「背景」用来放置草地,Layer2重命名为Player。创建完毕后,把Tilemap和Sprite的Sorting Layer分别设置为对应的图层就能正确显示了。 ## 6. 创建脚本文件 创建第一个脚本来实现让小人在世界中移动,右键Assets创建C#脚本文件,路径和文件名如下:`../Assets/Scripts/PlayerController.cs`,**注意:脚本的名称中不能有空格,并且每个单词的第一个字母都要大写,否则Unity会因无法识别文件类型而报错**。 脚本文件创建好后会自动生成一些代码,最顶部的是一些使用的系统集合和引擎声明,下面有一个启动函数和一个更新函数,启动函数是在第一帧更新之前调用的,更新函数每一帧会被调用一次。 我们在启动函数之前声明一个公共的速度变量 `public float moveSpeed;`,**变量名第一个单词字母小写,后面所有单词第一个字母大写**,公共变量的值可以在Unity Inspector中修改,这里把 `moveSpeed`的值更改为2。 ## 7. 让玩家通过WASD移动 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed; //玩家移动速度变量声明 // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { Vector3 moveInput = new Vector3(0f, 0f, 0f); moveInput.x = Input.GetAxisRaw("Horizontal"); moveInput.y = Input.GetAxisRaw("Vertical"); Debug.Log(moveInput); transform.position += moveInput * moveSpeed * Time.deltaTime; } } ``` `Vector3 moveInput = new Vector3(0f, 0f, 0f);`:在更新函数中声明一个新的三维向量,三维向量Vector3有三个值,对应空间坐标轴上的x,y,z。代码中 `0f`的含义是浮点数0。 `moveInput.x = Input.GetAxisRaw("Horizontal");` `moveInput.y = Input.GetAxisRaw("Vertical");`:这里的 `Hrizontal`和 `Vertical`可以在Edit→Project Settings→Input Manager中看到,这是Unity自带的输入设置,它会把WASD键和上下左右四个方向对应起来,而这里的 `Raw`表示的是获取输入的原始数据量,如果这里替换为 `Input.GetAxis`,`moveInput`则会从0到一平滑上升,**注意这里 `GetAxisRaw("")`双引号中的单词不能有拼错,拼错的话Unity不会报错,但是这行代码也不起作用**。 调试函数 `Debug.Log(moveInput);`:启动游戏后按WASD键可以在console中观察到moveInput的数值变化。 `transform.position += moveInput * moveSpeed * Time.deltaTime;`:通过改变玩家位置来移动玩家,这里的 `Time.deltaTime`代表帧率的倒数,如果去除玩家的移动速度将取决于游戏中的帧率。 ## 8. 限制斜向移动速度 当玩家斜向走时,例如朝左上方向走,这时 `moveInput.x`为-1,`moveInput.y`为1,而 `moveInput`的模为根号2,超过了沿水平和垂直方向时的速度。 `moveInput.Normalize();`:取三维向量的单位向量,避免使玩家斜向走的速度大于垂直和水平方向的速度。 ## 9. 镜头跟随 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class CameraController : MonoBehaviour { private Transform target; // Start is called before the first frame update void Start() { target = FindObjectOfType<PlayerController>().transform; } // Update is called once per frame void LateUpdate() { transform.position = new Vector3(target.position.x, target.position.y, transform.position.z); } } ``` 这时玩家如果移动超出摄像头范围就看不到了,我们要使摄像头始终跟随玩家。 创建一个新的C#脚本命名为CameraController并把它绑定到摄像头上。 声明一个私有的 `Transform`变量 `target`,私有的变量可以通过打开右侧栏的三个小点下的debug来观察。 `target = FindObjectOfType<PlayerController>().transform;`:绑定 `target`和 `Player`的 `transform`类型。`FindObjectOfType()`,按类型查找对象列表,这里查找的是PlayerController这个脚本所对应的 `transform`类型。 创建一个 `LateUpdate()`函数,这个函数会在任何Update函数运行过后运行一次。 `transform.position = new Vector3(target.position.x, target.position.y, transform.position.z);`:使摄像头位置坐标的x和y跟随玩家,而z坐标保持不变。 ## 10. 添加玩家动画 给人物添加一段动画来让玩家感觉到操纵的人物在移动。 点击Window→Animation→Animation打开动画窗口,点击Cerate创建一个玩家静止状态下的动画Player_Idle和一个移动状态下的动画Player_Moving,保存在 `../Assets/Animations/Player`下。选中Player_Moving的动画点击录制,拖动时间轴录制几个玩家在不同角度时的状态,这样玩家走动时会播放晃动的动画。 点击Window→Animation→Animator,点击Player_Idle添加一个Parameters命名为isMoving,右键Player_Idle点击Make Transition创建一个过渡连接到Player_Moving上,点击中间的连线把过渡时间调为0,反过来同样建立Player_Moving到Player_Idle的连线。 在PlayerController脚本中声明一个公共变量anim, `public Animator anim;`,在Unity Inspector中把动画绑定到anim变量上。 在更新函数的最后添加一段判断玩家是否移动的代码。 ```csharp if(moveInput != Vector3.zero) { anim.SetBool("isMoving", true); } else { anim.SetBool("isMoving", false); } ``` 其中,`Vector3.zero`等同于 `Vector3(0, 0, 0)`。 ## 11. 添加敌人 创建敌人的方法和玩家时相同,把敌人重命名为Sprite作为Enemy 1的子对象,另外创建一个新的图层Enemy,让玩家始终显示在敌人上方。 给**敌人和玩家**添加一个Circle Collider 2D「2D圆形碰撞器」,更改Radius到合适的范围,再给**敌人**添加一个Rigidbody 2D「2D刚体模型」,并把Gravity Scale设置为0以免受到重力影响从画面掉落。 当两个物体碰撞时可能会导致旋转,勾选Rigidbody 2D→Constrains「约束」→Freeze Rotation→Z可以避免物体在Z轴上的旋转。 ## 12. 让敌人追逐玩家 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemyController : MonoBehaviour { public Rigidbody2D theRB; public float moveSpeed; private Transform target; // Start is called before the first frame update void Start() { target = FindObjectOfType<PlayerController>().transform; } // Update is called once per frame void Update() { theRB.velocity = (target.position - transform.position).normalized * moveSpeed; } } ``` 创建一个C#脚本命名为EnemyController并绑定到Enemy 1上。把Enemy 1的RigidBody 2D绑定到公共变量 `theRB`上。 `theRB.velocity = (target.position - transform.position).normalized * moveSpeed;`: `theRB.velocity`:`velocity`是Unity内置的物理速度,基于游戏的固定更新,和游戏的帧率无关,不需要和玩家移动速度一样乘以 `Time.deltaTime`。 为了使敌人永远追逐玩家,需要计算出敌人与玩家的向量关系,即玩家位置减敌人位置,并且取单位向量作为敌人速度的方向。 ## 13. 添加敌人动画 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemyAnimation : MonoBehaviour { public Transform sprite; public float speed; public float minSize, maxSize; private float activeSize; // Start is called before the first frame update void Start() { activeSize = maxSize; speed = speed * Random.Range(.75f, 1.25f); } // Update is called once per frame void Update() { sprite.localScale = Vector3.MoveTowards(sprite.localScale, Vector3.one * activeSize, speed * Time.deltaTime); if(sprite.localScale.x == activeSize) { if(activeSize == maxSize) { activeSize = minSize; } else { activeSize = maxSize; } } } } ``` 如果像之前玩家动画那样给每个不同的敌人添加动画太过于繁琐,不如创建一个C#脚本统一处理敌人的动画。 创建一个C#脚本命名为EnemyAnimation并绑定到Enemy 1上。这个脚本的作用是使敌人1的大小从0.9到1.1之间不断变化。 把Sprite绑定到公共变量sprite上,`speed`,`maxSize`,`minSize`分别设置为0.5,1.1和0.9。 `MoveTowards(float current, float target, float maxDelta);`:把一个数平滑增大或者减小到某一数值,三个传递参数分别为:当前数值,目标数值,速度。 最后判断当前物体大小是否等同于设定的最大值或者最小值,并且相应更改activeSize的大小。 为了避免画面中所有的敌人的动画相同,在开始函数中给动画的速度乘以一个随机函数 `speed = speed * Random.Range(.75f, 1.25f);`使敌人动画的速度在0.75和1.25倍速之间浮动。 ## 14. 设置人物的血量 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerHealthController : MonoBehaviour { public float currentHealth, maxHealth; // Start is called before the first frame update void Start() { currentHealth = maxHealth; } // Update is called once per frame void Update() { if(Input.GetKeyDown(KeyCode.T)) { TakeDamage(10f); } } public void TakeDamage(float damageToTake) { currentHealth -= damageToTake; if(currentHealth <= 0) { gameObject.SetActive(false); } } } ``` 创建一个新的C#脚本命名为PlayerHealthController并绑定到Player上。 把 `maxHealth`的值设置为25。 创建一个用于扣血的函数 `public void TakeDamage(float damageToTake)`,当人物的血量小于等于0时,使人物对象失能 `gameObject.SetActive(false);`,`gameObject`在这里对应的是脚本所绑定的整个对象。 在更新函数中创建一段测试扣血的代码,游戏运行后按下T键就会扣玩家10血。KeyCode的变量表可以在[KeyCode - Unity 脚本 API](https://docs.unity.cn/cn/2020.3/ScriptReference/KeyCode.html)找到。 ## 15. 敌人对玩家造成伤害 为了能使所有敌人都能访问到玩家的生命值,在这里使用Singleton「单例类:保证每一个类仅有一个实例,并为它提供一个全局访问点」。在PlayerHealthController.cs里声明最大生命之前添加如下代码: ```csharp public static PlayerHealthController instance; private void Awake() { instance = this; } ``` **注意这里声明的instance是静态变量,虽然是公共的,但是不能在inspector中看到。** `Awake()`:Unity所自带的函数,当一个物体被激活时会运行此函数,并且它的运行早于Start()函数。 this代表当前类的实例对象。 需要通过检测敌人和玩家的碰撞来扣除玩家的生命值,打开EnemyController.cs,声明公共变量damage `public float damage;`把这个值设置为5,并在更新函数之后添加如下代码: ```csharp private void OnCollisionEnter2D(Collision2D collision) { if(collision.gameObject.tag == "Player") { PlayerHealthController.instance.TakeDamage(damage); } } ``` `OnCollisionEnter2D(Collision2D collision)`:当传入碰撞体与该对象的碰撞体接触时运行(仅限 2D 物理)。 在inspector内给玩家添加一个Player的tag,当检测到被碰撞物体的tag为Player时,会调用PlayerHealthController.cs中的 `TakeDamage()`函数扣除玩家生命值。 ## 16. 敌人攻击间隔 如果有大量的敌人同时碰撞到玩家,玩家的生命值流失会很快。需要给敌人一个攻击时间间隔。 在EnemyController.cs中添加两个新的变量: ```csharp public float hitWaitTime = 1f; private float hitCounter; ``` hitWaitTime为无敌帧倒计时,hitCounter用于重置计时器。 在更新函数中添加倒计时: ```csharp if(hitCounter > 0f) { hitCounter -= Time.deltaTime; } ``` 更新函数每一帧运行一次的,所以这里每次运行一次更新函数,计时器减去这一帧所用的时间。 在碰撞函数中添加计时器为0的判断 `if(collision.gameObject.tag == "Player" && hitCounter <=0f)` 计时器为零并且玩家受到伤害后,重置计时器:`hitCounter = hitWaitTime;` <h2 id="healthBar">17. 显示血条</h2> 在场景中右键Player→UI→Canvas创建一个画布重命名为Health Canvas,更改Render Mode「渲染模式」右键为World Space,右键Health Canvas→UI→Slider创建一个滑动条,删除Handle Slide Area「滑动按钮」,调整background和Fill Area的stretch方向为四个方向,把四个方向的数值都改为0,并其更改大小、背景图、位置和颜色让它看起来更像个血条。 ## 18. 血条实时显示血量 在PlayerHealthController.cs的开头添加 `using UnityEngine.UI;`让代码能够访问Unity的UI组件。 声明滑动条为血条 `public Slider healthSlider;`,之后在Inspector内绑定Slider。 ```csharp healthSlider.maxValue = maxHealth; healthSlider.value = currentHealth; ``` 在开始函数中设置血条的最大值以及当前血条的值和血量相对应。 在 `TakeDamage()`函数中添加 `healthSlider.value = currentHealth;`使血条和血量保持对应。 **取消勾选Slider→Interactable防止玩家拖动血条。** ## 19. 制作敌人预制件 创建../Assets/Prefabs/Enemies文件夹,把Enemy 1重命名为Green Bee拖到文件夹内,这样就生成了敌人的预制件。 拖动预制件到地图上会直接生成它的复制,预制件可以方便的修改所有同种物体的数值。修改预制件所复制物体的数值后在它的左侧会有一根竖着的蓝线,可以通过修改预制件所复制物体的Overrides内容来修改预制件本身的数值。 **注意:当改变复制物体的数值时,之后不会受到预制件基准预设值更改的影响,可以通过右键变量名选择revert来恢复。** ## 20. 自动生成敌人 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemySpawner : MonoBehaviour { public GameObject enemyToSpawn; // Start is called before the first frame update public float timeToSpawn; private float spawnCounter; void Start() { spawnCounter = timeToSpawn; } // Update is called once per frame void Update() { spawnCounter -= Time.deltaTime; if(spawnCounter <= 0) { spawnCounter = timeToSpawn; Instantiate(enemyToSpawn, transform.position, transform.rotation); } } } ``` 右键场景生成一个Empty Object重命名为Enemy Spawner,在地图上拖动它到你想生成敌人的位置。创建一个C#脚本命名为EnemySpawner并把它绑定到场景中的Enemy Spawner上。 声明要生成的敌人 `public GameObject enemyToSpawn;`绑定到Enemy- Green Bee上。 和之前无敌帧的倒计时相同,当倒计时小于等于0时重置倒计时并在当前位置实例化敌人:`Instantiate(enemyToSpawn, transform.position, transform.rotation);` [Object-Instantiate - Unity 脚本 API](https://docs.unity.cn/cn/2019.4/ScriptReference/Object.Instantiate.html) ## 21. 在屏幕外生成敌人 在场景中的Enemey Spawner上创建两个新的Empty Object分别命名为Min Spawn Point和Max Spawn Point,并把它们呈对角移到摄像头以外的区域,声明变量 `public Transform minSpawn, maxSpawn;`并绑定。 ![虚线的区域表示敌人的生成区域](http://cloud.nailoy.com/typecho/uploads/2024/02/1732492150.png) ```csharp public Vector3 SelectSpawnPoint() { Vector3 spawnPoint = Vector3.zero; bool spawnVerticalEdge = Random.Range(0f,1f) > .5f; if(spawnVerticalEdge) { spawnPoint.y = Random.Range(minSpawn.position.y, maxSpawn.position.y); if(Random.Range(0f,1f) > .5f) { spawnPoint.x = maxSpawn.position.x; } else { spawnPoint.x = minSpawn.position.x; } } else { spawnPoint.x = Random.Range(minSpawn.position.x, maxSpawn.position.x); if(Random.Range(0f,1f) > .5f) { spawnPoint.y = maxSpawn.position.y; } else { spawnPoint.y = minSpawn.position.y; } } return spawnPoint; } ``` 创建一个用于随机决定敌人生成区域的函数 `SelectSpwanPoint()`,这个函数返回值是一个Vector3「三维向量」,首先通过 `bool spawnVerticalEdge = Random.Range(0f,1f) > .5f;`判断生成在竖直边还是垂直边上,再通过相同的办法判断生成在剩余两条边的哪一侧。 ## 22. 修复一些小bug 防止敌人遮挡住玩家的血条,新建一个新的Sorting Layer命名为UI并移到其他图层之上。 把EnemyController.cs和CameraController.cs中的 `target = FindObjectOfTypeplayercontroller().transform;`替换为 `target = PlayerHealthController.instance.transform;` 另外在EnemySpawner.cs添加声明 `private Transform target;`,在开始函数中添加 `target = PlayerHealthController.instance.transform;`,并在更新函数的最后添加 `transform.position = target.position;`,使敌人生成器跟随玩家移动。 ## 23. 跟踪敌人 在EnemySpawner.cs中添加一个私有变量 `private float despawnDistance;`表示敌人距离玩家多远时会被清除。 在开始函数中添加 `despawnDistance = Vector3.Distance(transform.position, maxSpawn.position) + 4f;` `Vector3.Distance()`:计算3维空间中两点之间距离。 声明一个类型为GameObject的列表spawnedEnemies,`private Listgameobject spawnedEnemies = new Listgameobject();` 另外把更新函数中的实例化生成敌人 `Instantiate(enemyToSpawn, SelectSpawnPoint(), transform.rotation);`更改为 ```csharp GameObject newEnemy = Instantiate(enemyToSpawn, SelectSpawnPoint(), transform.rotation); spawnedEnemies.Add(newEnemy); ``` 把生成的敌人添加到之前声明的列表中。 ## 24. 清除较远处敌人 不断地计算所有的敌人和玩家之间的距离占用了大量的资源,可能会导致游戏跳帧,可以通过每隔一定时间检查部分敌人来进行优化。 打开EnemySpawner.cs,添加声明 `public int checkPerFrame;`和 `private int enemyToCheck;`,设置 `checkPerFrame`为10,`enemyToCheck`为具体要检查的敌人编号,`checkPerFrame`为每帧检查多少个敌人。 在更新函数的最后添加: ```csharp int checkTarget = enemyToCheck + checkPerFrame; while(enemyToCheck < checkTarget) { if(enemyToCheck < spawnedEnemies.Count) { if(spawnedEnemies[enemyToCheck] != null) { if(Vector3.Distance(transform.position, spawnedEnemies[enemyToCheck].transform.position) > despawnDistance) { Destroy(spawnedEnemies[enemyToCheck]); spawnedEnemies.RemoveAt(enemyToCheck); checkTarget--; } else { enemyToCheck++; } } else { spawnedEnemies.RemoveAt(enemyToCheck); checkTarget--; } } else { enemyToCheck = 0; checkTarget = 0; } } ``` `checkTarget`为我们要检查的敌人总数,`while(enemyToCheck < checkTarget)`当要检查的敌人编号小于总数时开始循环。`if(enemyToCheck < spawnedEnemies.Count)`判断敌人编号是否小于敌人列表长度,如果为否则跳出while循环。 `if(spawnedEnemies[enemyToCheck] != null)`判断敌人编号所对应的是否为空位,如果为空位,用 `RemoveAt()`函数删除且敌人编号减一,若不为空位继续判断 `if(Vector3.Distance(transform.position, spawnedEnemies[enemyToCheck].transform.position) > despawnDistance)`,如果敌人距离玩家太远则用 `Destroy()`函数清除敌人,同时删除空位且敌人编号减一。若敌人距离玩家小于清除距离,则敌人编号加一,继续检查下一个敌人。 ## 25. 添加武器 - 火球 添加一个可以围绕着玩家旋转的火球来攻击敌人。 在 `../Assets/Scripts/Weapons`文件夹下新建一个SpinWeapon.cs,在场景中新建Empty Object,路径如下Player/Weapons/Orbitting Fireball/Fireball Holder,绑定精灵图到其上并重命名为Fireball。 给FireBall添加一个Circle Collider 2D碰撞器,调整它的半径。 **Tips:为了增强玩家的游戏体验,武器的伤害范围通常比图片尺寸大一些。** ![](http://cloud.nailoy.com/typecho/uploads/2024/02/816706074.png) 打开SpinWeapon.cs,添加声明 `public Transform holder;`,把Fireball Holder绑定到上面。另外添加角速度声明 ` public float rotateSpeed;`设置为180,单位为度每秒。 在更新函数中添加 `holder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime));`,这里用欧拉角使火球旋转。 Unity中的旋转使用四元数或欧拉角,具体可以参考[Unity 中的旋转和方向 - Unity 手册](https://docs.unity.cn/cn/2019.4/Manual/QuaternionAndEulerRotationsInUnity.html)。 ## 26. 对敌人造成伤害 在EnemyController.cs中声明玩家的血量,和玩家一样添加一个TakeDamge()函数: ```csharp public void TakeDamage(float damageToTake) { health -= damageToTake; if(health <= 0) { Destroy(gameObject); } } ``` 如果敌人的血量小于0则清除敌人。 给Enemy - Green Bee添加一个Enemy的tag。 新建一个EnemyDamager.cs绑定到火球上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemyDamager : MonoBehaviour { public float damageAmount; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } private void OnTriggerEnter2D(Collider2D collision) { if(collision.tag == "Enemy") { collision.GetComponent<EnemyController>().TakeDamage(damageAmount); } } } ``` 声明火球的伤害 `public float damageAmount;`并设置为5。 `GameObject.GetComponent(type)`:如果游戏对象附加了类型为 `type` 的组件,则将其返回,否则返回 `null`。 ## 27. 给火球添加粒子效果 右键Fireball创建粒子效果Effects→Particale System。 在Inpector内找到Shape,把形状改为圆形并更改半径为火球的大小,在Emission中更改Rate over Time为25,另外设置Start Delay:0.5,Start Lifetime:0.5,Start Speed:0.1,Start Size:0.5,设置颜色为随机两种颜色,红色和橙色并调整透明度。Simulation Space从Local更改为World,这样可以显示出火焰的轨迹。另外Size Over LifeTime设置Size随着时间变小。Color Over LifeTime设置颜色随着时间逐渐变淡。 ![效果如图所示](http://cloud.nailoy.com/typecho/uploads/2024/02/3269850588.png) ## 28. 设置火球的持续和冷却时间 取消激活FireBall Holder并新建一个Holder: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3956682840.png) 打开SpinWeapon.cs,声明 `Holder`绑定到Holder,声明 `fireballToSpawn`绑定到Fireball Holder上。另外声明 `timeBetweenSpawn`和 `SpawnCounter`,为生成的冷却倒计时,其中设置 `timeBetweenSpawn`为4。 ```csharp public Transform holder, fireballToSpawn; public float timeBetweenSpawn; private float spawnCounter; ``` 在更新函数中添加火球生成的倒计时: ```csharp spawnCounter -= Time.deltaTime; if(spawnCounter <= 0) { spawnCounter = timeBetweenSpawn; Instantiate(fireballToSpawn, fireballToSpawn.position, fireballToSpawn.rotation, holder).gameObject.SetActive(true); } ``` Instantiate()函数第四个传递参数为,把实例化的对象作为此参数的子对象,这里倒计时结束后实例化生成为 `holder`的子对象并激活。 打开EnemyDamager.cs添加声明 `public float lifeTime;`并设置为3,`lifeTime`代表火球的持续时间。在开始函数中添加 `Destroy(gameObject, lifeTime);`,当经过 `lifeTime`后清除火球。 ## 29. 火球渐入渐出 打开EnemyDamager.cs,添加声明 `public float lifeTime, growSpeed = 5f;`和 `private Vector3 targetSize;` 注释开始函数中的Destroy()函数,添加: ```csharp targetSize = transform.localScale; transform.localScale = Vector3.zero; ``` 使 `targetSize`先等于当前火球的大小,之后再设置火球初始大小为0。 和敌人放大缩小的动画类似,在更新函数的最后添加: ```csharp transform.localScale = Vector3.MoveTowards(transform.localScale, targetSize, growSpeed * Time.deltaTime); lifeTime -= Time.deltaTime; if(lifeTime <=0 ) { targetSize = Vector3.zero; if(transform.localScale.x <= 0 ) { Destroy(gameObject); } } ``` 当火球持续时间结束后删除火球。 ## 30. 敌人击退 打开EnemyController.cs,声明 `public float knockBackTime = .5f;`和 `private float knockBackCount;` 创建一个新的函数用于判断哪些武器才能击退: ```csharp public void TakeDamage(float damageToTake, bool shouldKnockback) { TakeDamage(damageToTake); if(shouldKnockback == true) { knockBackCounter = knockBackTime; } } ``` 这个函数调用了之前的 `TakeDamge()`函数。 另外在EnemyDamager.cs中添加 `public bool shouldKnockback;`的声明并勾选设置为真,同时相应更改下方的 `TakeDamge()`函数。 在EnemyController.cs的更新函数开头添加击退判断: ```csharp if(knockBackCounter >0) { knockBackCounter -= Time.deltaTime; if(moveSpeed > 0) { moveSpeed = -moveSpeed * 2f; } if(knockBackCounter <=0 ) { moveSpeed = Mathf.Abs(moveSpeed * .5f); } } ``` `Mathf.Abs()`:取绝对值。 <h2 id="damageNumber">31. 显示伤害数字</h2> 在场景中创建Damage Number Contrller,创建画布Damage Number Canvas,和之前显示血量一样设置Render Mode为World Space并放置到世界中央,点击UI→Text创建文字框。结构如下Damage Number Contrller/Damage Number Canvas/Text (TMP)。 右键Fonts/Kenney Fonts点击Create→TextMeshPro→Font Assets生成字体的一个游戏用版本,绑定到Text上。设置字体大小为72,对齐方式选择垂直和水平居中;设置Dilate和Thickness为0.25给文字添加轮廓线。 ## 32. 编写伤害数字代码 创建DamageNumber.cs,绑定到Text (TMP)上。设置lifetime为2。 使用文本组件要在开头添加 `using TMPro`; ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; using TMPro; public class DamageNumber : MonoBehaviour { public TMP_Text damageText; public float lifetime; private float lifeCounter; // Update is called once per frame void Update() { if(lifeCounter > 0) { lifeCounter -= Time.deltaTime; if(lifeCounter <= 0) { Destroy(gameObject); } } } public void Setup(int damageDisplay) { lifeCounter = lifetime; damageText.text = damageDisplay.ToString(); } } ``` 创建DamageNumberController.cs,绑定两个公共变量 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class DamageNumberController : MonoBehaviour { public static DamageNumberController instance; private void Awake() { instance = this; } public DamageNumber numberToSpawn; public Transform numberCanvas; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } public void SpawnDamage(float damageAmount, Vector3 location) { int rounded = Mathf.RoundToInt(damageAmount); DamageNumber newDamage = Instantiate(numberToSpawn, location, Quaternion.identity, numberCanvas); newDamage.Setup(rounded); newDamage.gameObject.SetActive(true); } } ``` 取消勾选Text (TMP)的激活状态。 Mathf.RoundToInt():数字四舍五入到整数。 打开EnemyController.cs,在TakeDamge()函数最后添加 `DamageNumberController.instance.SpawnDamage(damageToTake, transform.position);`以生成伤害数字。 ## 33. 增强显示效果 设置Damage Number Canvas的Sorting Layer为UI以防敌人遮挡。 给伤害数字添加一个上浮的效果: 在DamageNumber.cs中添加 `public float floatSpeed = 1f;` 在更新函数中增加 `transform.position += Vector3.up * floatSpeed * Time.deltaTime;` `Vector3.up`:x = 1,y = 0,z = 0 ## 34. 建立伤害数字对象池 建立对象池重复利用伤害数字对象以减少游戏卡顿。 打开DamageNumberController.cs,添加声明 `private Listdamagenumber numberPool = new Listdamagenumber();` 创建 `GetFromPool()`函数用来从对象池中取出数字 ```csharp public DamageNumber GetFromPool() { DamageNumber numberToOutput = null; if(numberPool.Count ==0) { numberToOutput = Instantiate(numberToSpawn, numberCanvas); } else { numberToOutput = numberPool[0]; numberPool.RemoveAt(0); } return numberToOutput; } ``` 更改 `SpawnDamage()`函数中的 `DamageNumber newDamage = Instantiate(numberToSpawn, location, Quaternion.identity, numberCanvas);`为 `DamageNumber newDamage = GetFromPool();`。并在最后添加 `newDamage.transform.position = location;`设置其位置。 添加一个PlaceInPool()函数用于把伤害数值存放进对象池 ```csharp public void PlaceInPool(DamageNumber numberToPlace) { numberToPlace.gameObject.SetActive(false); numberPool.Add(numberToPlace); } ``` 在DamageNumber.cs中的更新函数中更改 `Destroy(gameObject);`为 `DamageNumberController.instance.PlaceInPool(this);` ### 【修复】删除未激活火球的父对象 在EnemyDamager.cs中添加声明 `public bool destroyParent;`并设置布尔值为true 在更新函数 `Destroy(gameObject);`后面添加 ```csharp if(destroyParent) { Destroy(transform.parent.gameObject); } ``` ## 35. 添加更多敌人 在场景中复制GreenBee的预制件,把它的精灵图更改为其他敌人,并相应地修改数值。 可以右键预制件的复制选择Prefab→Unpack解除和预制件的联系,变为一个独立的对象。 重命名新添加的敌人并生成它们的预制件。 ## 36. 敌人波次 打开EnemySpawner.cs,在 `EnemySpawner`类中添加声明 `public List<waveinfo> waves;` 创建一个新的公共类 `WaveInfo`用于储存不同敌人波次的数据。 **注意:由于新的类不在Unity的MonoBehaviour类中,所以无法在Inspector内看到其公共变量的值,并且也不会被检查更新。** ```csharp [System.Serializable] public class WaveInfo { public GameObject enemyToSpawn; public float waveLength = 10f; public float timeBetweenSpawns = 1f; } ``` `[System.Serializable]`:把其后的代码转换为数据,以在Inspector中显示出来。 点击EnemySpawner→Inspector→Waves的加号创建6个波次对应6个不同的敌人,绑定敌人预制件到EnemyToSpawn,设置WaveLength为10,TimeBetweenSpawn为0.5。 ## 37. 生成不同波次的敌人 打开EnemySpawner.cs在 `EnemySpawner`类中添加声明: ```csharp private int currentWave; private float waveCounter; ``` `waveCounter`:波次计时器。 后面不再依赖之前的敌人生成代码,**注释以下内容:** ```csharp /* spawnCounter -= Time.deltaTime; if(spawnCounter <= 0) { spawnCounter = timeToSpawn; //Instantiate(enemyToSpawn, transform.position, transform.rotation); GameObject newEnemy = Instantiate(enemyToSpawn, SelectSpawnPoint(), transform.rotation); spawnedEnemies.Add(newEnemy); } */ ``` 在注释的代码后面添加新的敌人生成代码: ```csharp if(PlayerHealthController.instance.gameObject.activeSelf) { if(currentWave < waves.Count) { waveCounter -= Time.deltaTime; if(waveCounter <= 0) { GoToNextWave(); } spawnCounter -= Time.deltaTime; if(spawnCounter <= 0) { spawnCounter = waves[currentWave].timeBetweenSpawns; GameObject newEnemy = Instantiate(waves[currentWave].enemyToSpawn, SelectSpawnPoint(), Quaternion.identity); spawnedEnemies.Add(newEnemy); } } } ``` 增加了玩家是否死亡的判断,添加了波次倒计时以及调用切换波次的函数,其余部分和之前的代码类似。 在 `EnemySpawner`类的最后添加 `GoToNextWave()`函数用以切换波次: ```csharp public void GoToNextWave() { currentWave++; if(currentWave >= waves.Count) { currentWave = waves.Count - 1; } waveCounter = waves[currentWave].waveLength; spawnCounter = waves[currentWave].timeBetweenSpawns; } ``` 当前波次大于波次列表长度时,停留在最后一个波次。 这时运行游戏会发现第一波的蜜蜂并没有生成,而是直接生成了第二波的敌人。这是由于波次倒计时 `waveCounter`初始值为0,跳过了第一波次的敌人。 修改开始函数为以下代码: ```csharp void Start() { //spawnCounter = timeToSpawn; target = PlayerHealthController.instance.transform; despawnDistance = Vector3.Distance(transform.position, maxSpawn.position) + 4f; currentWave = -1; GoToNextWave(); } ``` 使游戏一开始的波次为-1并立即跳转到下一波次。 ## 38. 经验系统 创建ExperienceLevelController.cs: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class ExprienceLevelController : MonoBehaviour { public static ExprienceLevelController instance; private void Awake() { instance = this; } public int currentExperience; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } public void GetExp(int amountToGet) { currentExperience += amountToGet; } } ``` 在场景中创建Experience Level Controller并绑定C#文件到其上;另外创建Experience Pickup并拖动精灵图到上面作为其子对象,再创建一个新的图层Pickups,使掉落物显示在敌人上方玩家下方。 ## 39. 拾取经验值 为Experience Pickup添加圆形碰撞器并设置大小,勾选Is Trigger,创建一个新的脚本ExpPickup.cs绑定到其上。 ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class ExpPickup : MonoBehaviour { public int expValue; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } private void OnTriggerEnter2D(Collider2D collision) { if(collision.tag == "Player") { ExperienceLevelController.instance.GetExp(expValue); Destroy(gameObject); } } } ``` 设置expValue为1。 给玩家添加一个刚体组件,设置Body Type为Kenematic,即则力、碰撞或关节将不再影响刚体。 打开debug模式开始游戏,可以看到玩家碰撞到经验后经验消失,`currentExperience`增加1。 ## 40. 拾取范围 在PlayerController.cs中添加拾取范围声明 `public float pickupRange = 1.5f;` 打开ExpPickup,添加以下声明,并设置moveSpeed「经验移动速度」为1: ```csharp private bool movingToPlayer; public float moveSpeed; public float timeBetweebChecks = .2f; private float checkCounter; private PlayerController player; ``` 在开始函数中添加: ```csharp player = PlayerHealthController.instance.GetComponent<PlayerController>(); ``` 在更新函数中添加: ```csharp if(movingToPlayer == true) { transform.position = Vector3.MoveTowards(transform.position, PlayerHealthController.instance.transform.position, moveSpeed * Time.deltaTime); } else { checkCounter -= Time.deltaTime; if(checkCounter <= 0) { checkCounter = timeBetweenChecks; if(Vector3.Distance(transform.position, PlayerHealthController.instance.transform.position) < player.pickupRange) { movingToPlayer = true; moveSpeed += player.moveSpeed; } } } ``` 当玩家和经验值的距离小于 `pickupRange`时,经验值向玩家移动。 ## 41. 使敌人掉落经验 制作经验值掉落物的预制件。 打开ExperienceLevelController.cs,添加声明 `public ExpPickup pickup;`并绑定经验值预制件。 创建一个函数用于生成掉落的经验: ```csharp public void SpawnExp(Vector3 position, int expValue) { Instantiate(pickup, position, Quaternion.identity).expValue = expValue; } ``` 打开EnemyController.cs,添加声明 `public int expToGive = 1;`,即敌人掉落多少经验值;分别设置不同敌人的 `expToGive`;另外,在 `TakeDamage()`函数的 `Destroy(gameObject);`后添加 ` ExperienceLevelController.instance.SpawnExp(transform.position, expToGive);`,在敌人死亡位置实例化经验值掉落物。 ## 42. 添加等级 打开ExperienceLevelController.cs,添加每级所需经验值,当前等级,等级上限的声明: ```csharp public List<int> expLevels; public int currentLevel = 1, levelCount = 100; ``` 为expLevels列表添加前7级所需的经验值: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1191161159.png) 在开始函数中添加: ```csharp while(expLevels.Count < levelCount) { expLevels.Add(Mathf.CeilToInt(expLevels[expLevels.Count - 1] * 1.1f)); } ``` 自动生成从8级到100级所需的经验值,每一级所需的经验是上一级的1.1倍。 `Mathf.CeilToInt()`:天花板函数,用进一法取整数。 ## 43. 升级 打开ExperienceLevelController.cs,在ExpGet()函数中添加升级判断: ```csharp if(currentExperience >= expLevels[currentLevel]) { LevelUp(); } ``` 创建LevelUp()「升级函数」: ```csharp void LevelUp() { currentExperience -= expLevels[currentLevel]; currentLevel++; if(currentLevel >= expLevels.Count) { currentLevel = expLevels.Count - 1; } } ``` 当玩家到达等级上限时,使玩家停留在最高等级。 ## 44. 显示经验条 右键场景UI→Canvas创建画布,重命名为UI Canvas,设置UI Scale Mode为Scale Width Or Height,设置Reference Resolution,X: 1920,Y: 1080。 ![在场景中添加经验条和文本框](http://cloud.nailoy.com/typecho/uploads/2024/02/235587485.png) 和之前设置血条和伤害数字类似,详见[17 - 显示血条](#healthBar),[31 - 显示伤害数字](#damageNumber)。 <img src="http://cloud.nailoy.com/typecho/uploads/2024/02/784944172.png" alt="效果如图所示" title="效果如图所示" style="" class="block" width=70% style=""> 添加完毕后设置经验条的Slider→Value为0。 ## 45. 更新经验条的显示 新建UIController.cs绑定到场景的UI Canvas上: ```csharp using System.Collections; using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; public class UIController : MonoBehaviour { public static UIController instance; private void Awake() { instance = this; } public Slider expLvlSlider; public TMP_Text expLvlText; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } public void UpdateExperiece(int currentExp, int levelExp, int currentLvl) { expLvlSlider.maxValue = levelExp; expLvlSlider.value = currentExp; expLvlText.text = "Level: " + currentLvl; } } ``` 分别绑定经验条Slider和等级文字Text到公共变量 `expLvlSlider`和 `expLvlText`上。 打开ExperienceLevelController.cs,在 `GetExp()`函数的最后添加 `UIController.instance.UpdateExperiece(currentExperience, expLevels[currentLevel], currentLevel);`调用UIController.cs中更新经验条的函数。 ## 46. 武器数值表01 新建Weapon.cs,用于储存武器的属性,因为不需要用到开始和更新函数,删除之: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class Weapon : MonoBehaviour { public List<WeaponStats> stats; public int weaponLevel; } [System.Serializable] public class WeaponStats { public float speed, damage, range, timeBtweenAttacks, amount, duration; } ``` 打开SpinWeapon.cs,修改 `public class SpinWeapon : MonoBehaviour`为 `public class SpinWeapon : Weapon`,使SpinWeapon类继承Weapon.cs的内容,可以直接使用Weapon.cs声明的所有变量。 ## 47. 武器数值表02 打开SpinWeapon.cs 添加声明 `public EnemyDamager damager;`并绑定Fireball到其上。 创建 `SetStats()`函数用以改变武器的属性: ```csharp public void SetStats() { damager.damageAmount = stats[weaponLevel].damage; transform.localScale = Vector3.one * stats[weaponLevel].range; timeBetweenSpawn = stats[weaponLevel].timeBtweenAttacks; damager.lifeTime = stats[weaponLevel].duration; spawnCounter = 0; } ``` 并在开始函数中调用此函数。`spawnCounter = 0;`:升级后立即刷新火球状态。 更新火球的旋转速度,修改更新函数中的 `holder.rotation`为 `holder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime * stats[weaponLevel].speed));` 修改武器的数值并在游戏中进行测试。 ## 48. 升级武器 为了方便调用玩家,打开PlayerController.cs添加 ```csharp public static PlayerController instance; private void Awake() { instance = this; } ``` 声明当前使用的武器 `public Weapon activeWeapon;`,绑定Orbiting Fireball到其上。由于Orbiting Fireball的Spin Weapon.cs继承了Weapon.cs,它也拥有Weapon组件。 在Weapon.cs的 `Weapon`类中添加升级更新声明: ```csharp [HideInInspector] public bool statsUpdated; ``` 另外添加武器武器升级函数: ```csharp public void LevelUp() { if (weaponLevel < stats.Count - 1) { weaponLevel++; statsUpdated = true; } } ``` 打开ExperienceLevelController.cs在Levelup()中调用此函数:`PlayerController.instance.activeWeapon.LevelUp();` 在SpinWeapon.cs中的更新函数中添加升级的判断,升级则更新武器属性状态: ```csharp if(statsUpdated ==true) { statsUpdated = false; SetStats(); } ``` 多添加几级的武器属性状态,并在游戏中测试武器的升级系统。 ## 49. 设置升级界面 右键UI Canvas创建面板,再在面板下创建一个按钮,并相应更改其属性如下图所示: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3474067280.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1953402590.png) ## 50. 更新升级按钮的显示 在Weapon.cs的Weapon类中添加升级描述图片的声明 `public Sprite icon;` ,在 `WeaponStats`类中添加升级描述文本的声明 `public string upgradeText;`,并相应的绑定它们。 新建 LevelUpSelectionButton.cs用于更新升级按钮的显示,绑定器到场景中的Level Up Button 1上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; public class LevelUpSelectionButton : MonoBehaviour { public TMP_Text upgradeDescText, nameLevelText; public Image weaponIcon; public void UpdateButtonDisplay(Weapon theWeapon) { upgradeDescText.text = theWeapon.stats[theWeapon.weaponLevel].upgradeText; weaponIcon.sprite = theWeapon.icon; nameLevelText.text = theWeapon.name + " - Lvl " +theWeapon.weaponLevel; } } ``` 打开UIController.cs添加声明按钮数组 `public LevelUpSelectionButton[] levelUpButtons;`并绑定: ![绑定后如图所示](http://cloud.nailoy.com/typecho/uploads/2024/02/2818762929.png) 在SpinWeapon.cs的开始函数中调用之前的更新升级按钮的函数,用于临时测试 ```csharp void Start() { SetStats(); UIController.instance.levelUpButtons[0].UpdateButtonDisplay(this); } ``` 开始游戏后升级,效果如下图所示 ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2086351460.png) ## 51. 继续给升级按钮添加功能 打开UIController.cs添加升级界面的声明并绑定Level Up Interface:`public GameObject levelUpPanel;` 再复制两个按钮放到左右两边,停用Level Up Interface: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2307067998.png) 打开ExperienceLevelController.cs更改升级函数为如下,升级时打开升级界面,游戏暂停,同时更新武器升级按钮的图标和文本: ```csharp void LevelUp() { currentExperience -= expLevels[currentLevel]; currentLevel++; if(currentLevel >= expLevels.Count) { currentLevel = expLevels.Count - 1; } //PlayerController.instance.activeWeapon.LevelUp(); UIController.instance.levelUpPanel.SetActive(true); Time.timeScale = 0f; UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.activeWeapon); } ``` `Time.timeScale`:时间流逝的标度。可用于慢动作效果。当 `timeScale` 为 1.0 时,时间流逝的速度与实时一样快。 当 `timeScale` 为 0.5 时,时间流逝的速度比实时慢 2x。当 `timeScale` 设置为 0 时,如果您的所有函数都是独立于帧率的, 则游戏基本上处于暂停状态。参见 [Time-timeScale - Unity 脚本 API](https://docs.unity.cn/cn/2019.4/ScriptReference/Time-timeScale.html)。 注释掉在50节中开始函数中增加的更新按钮的函数: `//UIController.instance.levelUpButtons[0].UpdateButtonDisplay(this);` 由于修改代码为显示升级面板中间的按钮,开始游戏效果如下图所示: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/652633936.png) ## 52. 点击升级按钮后关闭界面 在LevelUpSelectionButton.cs中添加声明`private Weapon assignedWeapon;`用于储存所选择武器的信息。 添加选择升级的函数,选择升级后,关闭升级界面并恢复游戏: ```csharp public void SelectUpgrade() { if(assignedWeapon != null) { assignedWeapon.LevelUp(); UIController.instance.levelUpPanel.SetActive(false); Time.timeScale = 1f; } } ``` 为每个按钮添加On Click(),点击按钮后运行SelectUpgrade()函数,如下图所示: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/4054927237.png) 进入游戏测试,当升级后弹出升级界面且游戏暂停,点击升级按钮则界面消失,游戏恢复正常,武器属性也得到增强。 ## 53. 随机初始武器 玩家升级和解锁更多的武器。 注释PlayerController.cs中的`//public Weapon activeWeapon;`,在其后新增一个未拥有武器列表和一个拥有的武器列表`public Listweapon unassignedWeapons, assignedWeapons;` 在ExperienceLevelController.cs中注释`//UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.activeWeapon);` 停用并在场景中复制几个Orbiting Fireball,把它们和unassignedWeapons绑定,如下图: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1228169938.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1746323532.png) 在PlayerController.cs中添加一个用于解锁武器的函数: ```csharp public void AddWeapon(int weaponNumber) { if(weaponNumber < unassignedWeapons.Count) { assignedWeapons.Add(unassignedWeapons[weaponNumber]); unassignedWeapons[weaponNumber].gameObject.SetActive(true); unassignedWeapons.RemoveAt(weaponNumber); } } ``` 在开始函数中调用此函数随机分配武器`AddWeapon(Random.Range(0, unassignedWeapons.Count));` Random.Range():取两个数字间的随机数,**int类型为开区间,float类型为闭区间** 此时进行游戏会发现,随机移动一个unassignedWeapons列表中的武器到assignedWeapons列表中,如下图: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2099167481.png) 在ExperienceLevelController.cs之前注释的位置,添加修改过后的代码: ```csharp UIController.instance.levelUpButtons[0].UpdateButtonDisplay(PlayerController.instance.assignedWeapons[0]); UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[0]); UIController.instance.levelUpButtons[2].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[1]); ``` 升级后可看到升级按钮正确显示。 ## 54. 解锁新武器 当升级时检测武器是否解锁,如果没有则按钮显示解锁,在LevelUpSelectionButton.cs的`UpdateButtonDisplay()`函数中增加判断: ```dacsharp public void UpdateButtonDisplay(Weapon theWeapon) { if(theWeapon.gameObject.activeSelf ==true) { upgradeDescText.text = theWeapon.stats[theWeapon.weaponLevel].upgradeText; weaponIcon.sprite = theWeapon.icon; nameLevelText.text = theWeapon.name + " - Lvl " +theWeapon.weaponLevel; } else { upgradeDescText.text = "Unlock " + theWeapon.name; weaponIcon.sprite = theWeapon.icon; nameLevelText.text = theWeapon.name; } assignedWeapon = theWeapon; }d ``` 当点击解锁按钮时,解锁新武器,打开LevelUpSelectionButton.cs,在`SelectUpgrade()`函数中增加判断: ```csharp public void SelectUpgrade() { if(assignedWeapon != null) { if(assignedWeapon.gameObject.activeSelf ==true) { assignedWeapon.LevelUp(); } else { PlayerController.instance.AddWeapon(assignedWeapon); } UIController.instance.levelUpPanel.SetActive(false); Time.timeScale = 1f; } } ``` 在PlayerController.cs中增加新的添加武器函数: ```csharp public void AddWeapon(Weapon weaponToAdd) { weaponToAdd.gameObject.SetActive(true); assignedWeapons.Add(weaponToAdd); unassignedWeapons.Remove(weaponToAdd); } ``` **可以发现我们这里拥有两个`AddWeapon()`函数,具体调用的是哪个函数根据它们的传参类型而定。** ## 55. 升级时显示解锁新武器选项 注释之前在ExperienceLevelController.cs中所编写的硬编码: ```csharp //UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.activeWeapon); //UIController.instance.levelUpButtons[0].UpdateButtonDisplay(PlayerController.instance.assignedWeapons[0]); //UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[0]); //UIController.instance.levelUpButtons[2].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[1]); ``` 在PlayerContoller.cs中添加最大武器上限声明`public int maxWeapons = 3;` 在ExperienceLevelController.cs添加声明`public Listweapon weaponsToUpgrade;`,用于在屏幕上显示我们要升级哪三种武器。 修改`LevelUp()`优先显示一个已解锁武器的升级选项,当拥有武器数量小于`maxWeapons`时,剩下的升级选项用升级武器或解锁其他武器补足: ```csharp weaponsToUpgrade.Clear(); List<Weapon> avalibaleWeapons = new List<Weapon>(); avalibaleWeapons.AddRange(PlayerController.instance.assignedWeapons); if(avalibaleWeapons.Count > 0) { int selected = Random.Range(0, avalibaleWeapons.Count); weaponsToUpgrade.Add(avalibaleWeapons[selected]); avalibaleWeapons.RemoveAt(selected); } if(PlayerController.instance.assignedWeapons.Count < PlayerController.instance.maxWeapons) { avalibaleWeapons.AddRange(PlayerController.instance.unassignedWeapons); } for(int i = weaponsToUpgrade.Count; i < 3; i++) { if(avalibaleWeapons.Count > 0) { int selected = Random.Range(0, avalibaleWeapons.Count); weaponsToUpgrade.Add(avalibaleWeapons[selected]); avalibaleWeapons.RemoveAt(selected); } } for(int i = 0; i < weaponsToUpgrade.Count; i++) { UIController.instance.levelUpButtons[i].UpdateButtonDisplay(PlayerController.instance.assignedWeapons[i]); } ``` List.AddRange():把一个列表的所有元素添加到列表List中。 **注意:从一个列表添加元素到另一个列表时,首先要判断列表长度是否大于0!** ## 56. 隐藏满级武器的升级选项 在PlayerController.cs中添加满级武器列表声明: ```csharp [HideInInspector] public List<Weapon> fullyLevelledWeapons = new List<Weapon>(); ``` 更改Weapon.cs的LevelUp()函数: ```csharp public void LevelUp() { if (weaponLevel < stats.Count - 1) { weaponLevel++; statsUpdated = true; if(weaponLevel >= stats.Count -1) { PlayerController.instance.fullyLevelledWeapons.Add(this); PlayerController.instance.assignedWeapons.Remove(this); } } } ``` 当武器满级时从assignedWeapons列表中移到fullyLevelledWeapons列表中。 在ExperienceContoller.cs中修改`LevelUp()`函数中的判断`if(PlayerController.instance.assignedWeapons.Count < PlayerController.instance.maxWeapons)`为`if(PlayerController.instance.assignedWeapons.Count + PlayerController.instance.fullyLevelledWeapons.Count < PlayerController.instance.maxWeapons)`。 此时升级界面不会显示满级武器的升级图标和文本描述。 更进一步,使升级界面不显示满级武器的升级按钮,在`LevelUp()`最后添加: ```csharp for(int i = 0; i < UIController.instance.levelUpButtons.Length; i++) { if(i < weaponsToUpgrade.Count) { UIController.instance.levelUpButtons[i].gameObject.SetActive(true); } else { UIController.instance.levelUpButtons[i].gameObject.SetActive(false); } } ``` ## 57. 跳过升级界面 创建一个跳过升级界面的按钮: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/641138812.png) 在UIController.cs中添加跳过升级界面的函数: ```csharp public void SkipLevelUp() { levelUpPanel.SetActive(false); Time.timeScale = 1; } ``` 把按钮的OnClick()绑定到此函数上,如下图: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3337492820.png) ## 58. 火球升级时增加数量 当火球数量增加时位置需要均匀分布,例如玩家操控3个火球时,火球所在角度应分别为0°,120°,240°。 修改SpinWeapon.cs中的更新函数: ```csharp void Update() { //holder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime)); holder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime * stats[weaponLevel].speed)); spawnCounter -= Time.deltaTime; if(spawnCounter <= 0) { spawnCounter = timeBetweenSpawn; //Instantiate(fireballToSpawn, fireballToSpawn.position, fireballToSpawn.rotation, holder).gameObject.SetActive(true); for(int i = 0; i < stats[weaponLevel].amount; i++) { float rot = 360f / stats[weaponLevel].amount * i; Instantiate(fireballToSpawn, fireballToSpawn.position, Quaternion.Euler(0f, 0f, rot), holder).gameObject.SetActive(true); } } if(statsUpdated ==true) { statsUpdated = false; SetStats(); } } ``` 修改每个等级中火球属性的amount,可以发现火球升级后均匀分布: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/898271366.png) ## 59. 设置火球的数值 设置火球的每个等级的数据到一个合理的值: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/347437899.png) ## 60. 增加武器 - 光环 在场景中创建范围伤害武器Bright Zone并添加Circle Collider 2D和EnemyDamager.cs组件: 、..![](http://cloud.nailoy.com/typecho/uploads/2024/02/3619310469.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3431190799.png) 打开EnemyDamager.cs添加新的变量声明: ```csharp public bool damageOverTime; public float timeBetweenDamage; private float damageCounter; private List<EnemyController> enemiesInRange = new List<EnemyController>(); ``` damageOverTime:是否持续伤害。 timeBetweenDamge:攻击时间间隔。 damageCounter:用于攻击时间间隔的倒计时。 enemiesInRange:用于追踪攻击范围内的敌人。 在Inspector内修改它们的数值,如下图所示: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2920550664.png) 更改`OnTriggerEnter2D()`函数为如下: ```csharp private void OnTriggerEnter2D(Collider2D collision) { if(damageOverTime == false) { if(collision.tag == "Enemy") { collision.GetComponent<EnemyController>().TakeDamage(damageAmount, shouldKnockback); } } else { if(collision.tag =="Enemy") { enemiesInRange.Add(collision.GetComponent<EnemyController>()); } } } ``` 增加判断是否持续伤害,如果是,则把范围内的敌人增加到enemiesInRange列表中。 添加`OnTriggerExit2D()`函数,从enemiesInRange列表中移除离开伤害范围的敌人: ```csharp private void OnTriggerExit2D(Collider2D collision) { if(damageOverTime == true) { if(collision.tag == "Enemy") { enemiesInRange.Remove(collision.GetComponent<EnemyController>()); } } } ``` 在更新函数中添加对攻击范围内敌人造成伤害的代码。并且当敌人死亡后,移除空位。在更新函数最后添加: ```csharp if(damageOverTime == true) { damageCounter -= Time.deltaTime; if(damageCounter <= 0) { damageCounter = timeBetweenDamage; for(int i =0; i < enemiesInRange.Count; i++) { if(enemiesInRange[i] != null) { enemiesInRange[i].TakeDamage(damageAmount, shouldKnockback); } else { enemiesInRange.RemoveAt(i); i--; } } } } ``` 效果如下图: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2871459167.png) ## 61. 生效新的武器 在...Assets/Scripts/Weapons下新建ZoneWeapon.cs并绑定其到ZoneWeapon上,绑定Zone Weapon Effect到damager,ZoneWeapons.cs内容如下: ```csharp using System.Collections; using System.Collections.Generic; using Unity.Mathematics; using UnityEngine; public class ZoneWeapon : Weapon { public EnemyDamager damager; private float spawnTime, spawnCounter; // Start is called before the first frame update void Start() { SetStats(); } // Update is called once per frame void Update() { if(statsUpdated ==true) { statsUpdated = false; SetStats(); } spawnCounter -= Time.deltaTime; if(spawnCounter <= 0f) { spawnCounter = spawnTime; Instantiate(damager, damager.transform.position, Quaternion.identity, transform).gameObject.SetActive(true); } } void SetStats() { damager.damageAmount = stats[weaponLevel].damage; damager.lifeTime = stats[weaponLevel].duration; damager.timeBetweenDamage = stats[weaponLevel].speed; damager.transform.localScale = Vector3.one * stats[weaponLevel].range; spawnTime = stats[weaponLevel].timeBtweenAttacks; spawnCounter = 0f; } } ``` 为ZoneWeapon添加几个等级并相应修改数据: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2444495327.png) 临时修改PlayerController.cs的开始函数用于测试新武器: ```csharp void Start() { if(assignedWeapons.Count == 0) { AddWeapon(Random.Range(0, unassignedWeapons.Count)); } } ``` 游戏开始时会使用`AddWeapon()`实例化生成新武器,停用Zone Weapon Effect防止报错,升级界面可以正确显示升级按钮的图标和文字描述,并且点击升级按钮后武器会提升相应属性,测试如下图: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/4182023016.png) ## 62. 添加射击武器 - 匕首 射击类型武器,发射物和敌人碰撞后摧毁自身并对敌人造成伤害。 在EnemyDamager.cs中添加声明`public bool destroyOnImpact;`。 `destroyOnImpact`:碰撞后是否摧毁自身。 在`OnTriggerEnter2D()`函数中添加`destroyOnImpact`判断,修改后如下: ```csharp private void OnTriggerEnter2D(Collider2D collision) { if(damageOverTime == false) { if(collision.tag == "Enemy") { collision.GetComponent<EnemyController>().TakeDamage(damageAmount, shouldKnockback); if(destroyOnImpact == true) { Destroy(gameObject); } } } else { if(collision.tag =="Enemy") { enemiesInRange.Add(collision.GetComponent<EnemyController>()); } } } ``` 在场景的Weapons下创建Dagger Projectile,绑定匕首的精灵图到其上: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2632376234.png) 新增一个Sorting Layer,命名为Weapons,把武器显示在敌人和掉落物的上方,修改所有武器的Sorting Layer为Weapons: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2771721943.png) 为匕首添加2D盒子碰撞器,勾选Is Trigger,添加EnemyDamager.cs组件,勾选destroyOnImpact。 在...Assets/Scripts/Weapons文件夹内新建Projectile.cs,绑定到Dagger Projectile上,Projectile.cs内容如下: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class Projectile : MonoBehaviour { public float moveSpeed; // Update is called once per frame void Update() { transform.position += transform.up * moveSpeed * Time.deltaTime; } } ``` **transform.up:朝物体自身所在坐标系上方的向量。** 在Inspector内设置moveSpeed为2,进入游戏测试,可发现无论怎样旋转物体方向,物体总是朝着自身的顶部运动。 ## 63. 匕首自动追踪 在场景中的Weapons下创建Dagger Throw,作为Dagger Projectile的父对象,这里先停用Dagger Projectile,后续通过代码来生成: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3352702804.png) 创建ProjectileWeapon.cs并绑定到Dagger Throw: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class ProjectileWeapon : Weapon { public EnemyDamager damager; public Projectile projectile; private float shotCounter; public float weaponRange; public LayerMask whatIsEnemy; // Start is called before the first frame update void Start() { SetStats(); } // Update is called once per frame void Update() { if(statsUpdated ==true) { statsUpdated = false; SetStats(); } } void SetStats() { damager.damageAmount = stats[weaponLevel].damage; damager.lifeTime = stats[weaponLevel].duration; damager.transform.localScale = Vector3.one * stats[weaponLevel].range; shotCounter = 0f; projectile.moveSpeed = stats[weaponLevel].speed; } } ``` 更改场景中Player的Inspector如下图: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1189349763.png) 把所有敌人的Layer绑定到一个新的Enemy Layer上: ![Layer和Sorting Layer并不相同](http://cloud.nailoy.com/typecho/uploads/2024/02/1197768138.png) 对应的绑定ProjectileWeapon.cs中声明的公共变量: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1511116442.png) 在ProjectileWeapon.cs的更新函数的最后添加实例化远程武器,朝最近的敌人自动发射: ```csharp shotCounter -= Time.deltaTime; if(shotCounter <= 0) { shotCounter = stats[weaponLevel].timeBtweenAttacks; Collider2D[] enemies = Physics2D.OverlapCircleAll(transform.position, weaponRange * stats[weaponLevel].range, whatIsEnemy); if(enemies.Length > 0) { for(int i = 0; i < stats[weaponLevel].amount; i++) { Vector3 targetPosition = enemies[Random.Range(0, enemies.Length)].transform.position; Vector3 direction = targetPosition - transform.position; float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; angle -= 90; projectile.transform.rotation = Quaternion.AngleAxis(angle, Vector3.forward); Instantiate(projectile, projectile.transform.position, projectile.transform.rotation).gameObject.SetActive(true); } } } ``` `Physics2D.OverlapCircleAll (Vector2 point, float radius, int layerMask= DefaultRaycastLayers, float minDepth= -Mathf.Infinity, float maxDepth= Mathf.Infinity)`:获取位于某圆形区域内的所有碰撞体的列表。该函数类似于 [OverlapCircle](https://docs.unity.cn/cn/2019.4/ScriptReference/Physics2D.OverlapCircle.html),不同之处在于其返回位于该圆形内的所有碰撞体。返回的数组中的碰撞体按 Z 坐标增大的顺序排序。如果该圆形内没有任何碰撞体,则返回一个空数组。 > [Physics2D-OverlapCircleAll - Unity 脚本 API](https://docs.unity.cn/cn/2019.4/ScriptReference/Physics2D.OverlapCircleAll.html) `Mathf.Atan2(float y, float x)`:返回其 `Tan` 为 `y/x` 的角度(以弧度为单位)。返回值是 X 轴与 2D 向量(从零开始,在 `(x, y)` 处终止)之间的角度。 `Mathf.Rad2Deg`:弧度到度换算常量(只读)。数值等于`360 / (2 * π)`。 `Quaternion.AngleAxis(float angle, Vector3 axis)`:创建一个围绕 `axis` 旋转 `angle` 度的旋转。 假设敌人在B点,玩家在A点,`Mathf.Atan2()`计算出的角度`Angle`如下图所示,和`Mathf.Rad2Deg`相乘弧度转为度,则匕首需要顺时针旋转`90°-Angle`才能朝向敌人。 <img src="http://cloud.nailoy.com/typecho/uploads/2024/02/3893936703.png" alt="计算结果为与X轴夹角的弧度" title="计算结果为与X轴夹角的弧度" style="" class="block" width=40% style=""> 在旋转后的位置实例化生成匕首并激活,朝向敌人发射。 设置匕首每一级的属性,如下图所示: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/687313465.png) 在游戏中进行测试: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/859056882.png) ## 64. 添加武器 - 剑 和匕首类似,添加2D盒子碰撞器和EnemyDamager.cs组件,相应修改数值: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3459673519.png) 在场景中停用Sword,后续通过代码来生成。 ## 65. 生成剑的戳刺攻击 创建Close Attack Weapons.cs绑定到Giant Sword上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UIElements; public class CloseAttackWeapon : Weapon { public EnemyDamager damager; private float attackCounter, direction; // Start is called before the first frame update void Start() { SetStats(); } // Update is called once per frame void Update() { if(statsUpdated ==true) { statsUpdated = false; SetStats(); } attackCounter -= Time.deltaTime; if(attackCounter <= 0) { attackCounter = stats[weaponLevel].timeBtweenAttacks; direction = Input.GetAxisRaw("Horizontal"); if(direction != 0) { if(direction > 0) { damager.transform.rotation = Quaternion.identity; } else { damager.transform.rotation = Quaternion.Euler(0f, 0f, 180f); } } Instantiate(damager, damager.transform.position, damager.transform.rotation, transform).gameObject.SetActive(true); for(int i = 1; i < stats[weaponLevel].amount; i++) { float rot = 360f / stats[weaponLevel].amount * i; Instantiate(damager, damager.transform.position, Quaternion.Euler(0f, 0f, damager.transform.rotation.eulerAngles.z + rot), transform).gameObject.SetActive(true); } } } void SetStats() { damager.damageAmount = stats[weaponLevel].damage; damager.lifeTime = stats[weaponLevel].duration; damager.transform.localScale = Vector3.one * stats[weaponLevel].range; attackCounter = 0f; } } ``` 实测如下: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2082949311.png) ## 66. 添加武器 - 斧头 在场景中创建斧子,添加2D盒子碰撞器,EnemyDamager.cs和2D刚体组件: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1160449589.png) 创建ThrowWeapon.cs绑定到场景中的Axe: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class ThrowWeapon : MonoBehaviour { public float throwPower; public Rigidbody2D theRB; public float rotateSpeed; // Start is called before the first frame update void Start() { theRB.velocity = new Vector2(Random.Range(-throwPower, throwPower), throwPower); } // Update is called once per frame void Update() { transform.rotation = Quaternion.Euler(0f, 0f, transform.rotation.eulerAngles.z - (rotateSpeed * 360f * Mathf.Sign(theRB.velocity.x) * Time.deltaTime)); } } ``` 给斧头一个 (-5~5,5) 的速度,并且当斧头朝右时顺时针旋转,当斧头朝左时逆时针旋转。 Math.sign(float f):返回 `f` 的符号。当 `f` 为正数或零时,返回值为 1,当 `f` 为负数时,返回值为 -1。 ## 67. 生成投掷出的斧头 新建WeaponThrower.cs,和其他武器类似: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class WeaponThrower : Weapon { public EnemyDamager damager; private float throwCounter; // Start is called before the first frame update void Start() { SetStats(); } // Update is called once per frame void Update() { if(statsUpdated ==true) { statsUpdated = false; SetStats(); } throwCounter -= Time.deltaTime; if(throwCounter <= 0) { throwCounter = stats[weaponLevel].timeBtweenAttacks; for(int i = 0; i < stats[weaponLevel].amount; i++) { Instantiate(damager,damager.transform.position, damager.transform.rotation).gameObject.SetActive(true); } } } void SetStats() { damager.damageAmount = stats[weaponLevel].damage; damager.lifeTime = stats[weaponLevel].duration; damager.transform.localScale = Vector3.one * stats[weaponLevel].range; throwCounter = 0f; } } ``` ## 68. 死亡后冻结敌人 打开EnemyController.cs在更新函数中增加玩家死亡判断: ```csharp void Update() { if(PlayerController.instance.gameObject.activeSelf == true) { if(knockBackCounter >0) { knockBackCounter -= Time.deltaTime; if(moveSpeed > 0) { moveSpeed = -moveSpeed * 2f; } if(knockBackCounter <=0 ) { moveSpeed = Mathf.Abs(moveSpeed * .5f); } } theRB.velocity = (target.position - transform.position).normalized * moveSpeed; if(hitCounter > 0f) { hitCounter -= Time.deltaTime; } } else { theRB.velocity = Vector2.zero; } } ``` 当玩家死亡后冻结所有敌人。 ## 69. 金币系统 在场景中创建Coin Controller,新建CoinController.cs绑定到其上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class CoinController : MonoBehaviour { public static CoinController instance; private void Awake() { instance = this; } public int currentCoins; public void AddCoins(int coinsToAdd) { currentCoins += coinsToAdd; } } ``` 把金币的精灵图拖到场景中命名为Coin,添加2D圆形碰撞器,设置Sorting Layer为Pickups,勾选Is Trigger。 创建CoinPickup.cs绑定到Coin上,内容和经验拾取类似: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class CoinPickup : MonoBehaviour { public int coinAmount = 1; private bool movingToPlayer; public float moveSpeed; public float timeBetweenChecks = .2f; private float checkCounter; private PlayerController player; // Start is called before the first frame update void Start() { player = PlayerController.instance; } // Update is called once per frame void Update() { if(movingToPlayer == true) { transform.position = Vector3.MoveTowards(transform.position, PlayerHealthController.instance.transform.position, moveSpeed * Time.deltaTime); } else { checkCounter -= Time.deltaTime; if(checkCounter <= 0) { checkCounter = timeBetweenChecks; if(Vector3.Distance(transform.position, PlayerHealthController.instance.transform.position) < player.pickupRange) { movingToPlayer = true; moveSpeed += player.moveSpeed; } } } } private void OnTriggerEnter2D(Collider2D collision) { if(collision.tag == "Player") { CoinController.instance.AddCoins(coinAmount); Destroy(gameObject); } } } ``` 更改CoinAmount的数值,进入游戏捡起金币,CurrentCoins数值得到增加。 ## 70. 随机掉落金币 制作金币的预制件。 金币掉落位置略微偏离敌人死亡的位置,并修改掉落金币的价值为`value`。在CoinController.cs中添加金币声明和金币掉落函数: ```csharp public CoinPickup coin; public void DropCoin(Vector3 position, int value) { CoinPickup newCoin = Instantiate(coin, position + new Vector3(.2f, .1f, 0f), Quaternion.identity); newCoin.coinAmount = value; newCoin.gameObject.SetActive(true); } ``` 绑定金币预制件到CoinController的公共变量`Coin`上。 在EnemyController.cs中添加掉落金币数量和金币掉落率的声明: ```csharp public int coinValue = 1; public float coinDropRate = .5f; ``` 在`TakeDamge()`函数的敌人死亡掉落经验值后面添加掉落金币: ```csharp if(Random.value <= coinDropRate) { CoinController.instance.DropCoin(transform.position, coinValue); } ``` `Random.value`:随机返回一个 0.0 到 1.0 之间的浮点数。 进入游戏测试,击杀敌人50%概率掉落金币: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3606103512.png) ## 71. 在UI中显示金币数量 在UI Canvas下创建CoinText和CoinImage,放到右上角: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1392814685.png) 打开UIController.cs,添加金币文本声明`public TMP_Text coinText;`并绑定,添加更新金币文本的函数: ```csharp public void UpdateCoins() { coinText.text = "Coins: " + CoinController.instance.currentCoins; } ``` 在CoinController.cs的`AddCoins()`函数中调用此函数: ```csharp public void AddCoins(int coinsToAdd) { currentCoins += coinsToAdd; UIController.instance.UpdateCoins(); } ``` 进入游戏测试,右上角实时显示金币数量的变化。 ## 72. 玩家数值表 在场景中创建Player Stat Controller,新建PlayerStatController.cs和其绑定: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStatController : MonoBehaviour { public static PlayerStatController instance; private void Awake() { instance = this; } public List<PlayerStatValue> moveSpeed, health, pickupRange, maxWeapon; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } } [System.Serializable] public class PlayerStatValue { public int cost; public float value; } ``` 升级后除了最大武器数量限制为5以外,其他数据的变化通过代码来自动生成,先设置玩家属性表如下图所示: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/207670979.png) 添加各个属性的最大等级声明`public int moveSpeedLevelCount, healthLevelCount, pickupRangeLevelCount;`,在Inspector都设置为50。 添加每个属性的当前等级声明`public int moveSpeedLevel, healthLevel, pickupRangeLevel, maxWeaponLevel;` 在开始函数中添加自动生成每级属性: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStatController : MonoBehaviour { public static PlayerStatController instance; private void Awake() { instance = this; } public List<PlayerStatValue> moveSpeed, health, pickupRange, maxWeapon; public int moveSpeedLevelCount, healthLevelCount, pickupRangeLevelCount; public int moveSpeedLevel, healthLevel, pickupRangeLevel, maxWeaponLevel; // Start is called before the first frame update void Start() { for(int i = moveSpeed.Count - 1; i < moveSpeedLevelCount; i++) { moveSpeed.Add(new PlayerStatValue(moveSpeed[i].cost + moveSpeed[1].cost, moveSpeed[i].value + (moveSpeed[1].value - moveSpeed[0].value))); } for(int i =health.Count - 1; i < healthLevelCount; i++) { health.Add(new PlayerStatValue(health[i].cost + health[1].cost, health[i].value + (health[1].value - health[0].value))); } for(int i =pickupRange.Count - 1; i < pickupRangeLevelCount; i++) { pickupRange.Add(new PlayerStatValue(pickupRange[i].cost + pickupRange[1].cost, pickupRange[i].value + (pickupRange[1].value - pickupRange[0].value))); } } // Update is called once per frame void Update() { } } [System.Serializable] public class PlayerStatValue { public int cost; public float value; public PlayerStatValue(int newCost, float newValue) { cost = newCost; value = newValue; } } ``` ## 73. 设置玩家数值升级UI 创建新的玩家升级界面Panel,按钮和描述文本: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1494548605.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/555811046.png) 创建PlayerStatUpgradeDisplay.cs绑定到场景中的PlayerStatUpgrade上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; using TMPro; public class PlayerStatUpgradeDisplay : MonoBehaviour { public TMP_Text valueText, costText; public GameObject upgradeButton; } ``` 使场景中的物体和声明绑定。 同样的制作其他几个属性的升级按钮: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2955522235.png) ## 74. 更新UI显示 **一般情况下,不要直接引用在不同层结构的另一个子对象。若被引用的对象在游戏中被移除后,会导致导致bug和报错。** 保险起见,这里让Player Stat Controller通过UI Controller分别来访问这些升级按钮。 打开PlayerStatUpgradeDisplay.cs添加更新显示函数: ```csharp public void UpdateDisplay(int cost, float oldValue, float newValue) { valueText.text = "Value: " + oldValue.ToString("F1") + "->" +newValue.ToString("F1"); costText.text = "Cost: " + cost; if(cost <= CoinController.instance.currentCoins) { upgradeButton.SetActive(true); } else { upgradeButton.SetActive(false); } } ``` 在UIController.cs的`PlayerStatController`类中,添加声明`public PlayerStatUpgradeDisplay moveSpeedUpgradeDispaly, healthUpgradeDisplay, pickupRangeUpgradeDisplay, maxWeaponUpgradeDisplay;`分别和场景中的四个升级界面绑定 在PlayerStatController.cs添加UpdateDisplay()函数,此函数调用了上文中在PlayerStatUpgradeDisplay.cs中的UpdateDisplay()函数: ```csharp public void UpdateDisplay() { UIController.instance.moveSpeedUpgradeDisplay.UpdateDisplay(moveSpeed[moveSpeedLevel+1].cost, moveSpeed[moveSpeedLevel].value, moveSpeed[moveSpeedLevel+1].value); UIController.instance.healthUpgradeDisplay.UpdateDisplay(health[healthLevel+1].cost, health[healthLevel].value, health[healthLevel+1].value); UIController.instance.pickupRangeUpgradeDisplay.UpdateDisplay(pickupRange[pickupRangeLevel+1].cost, pickupRange[pickupRangeLevel].value, pickupRange[pickupRangeLevel+1].value); UIController.instance.maxWeaponUpgradeDisplay.UpdateDisplay(maxWeapon[maxWeaponLevel+1].cost, maxWeapon[maxWeaponLevel].value, maxWeapon[maxWeaponLevel+1].value); } ``` 在ExperienceLevelController.cs的升级函数LevelUp()最后调用此函数`PlayerStatController.instance.UpdateDisplay();` 当拥有足够的金币可以升级时,显示升级按钮: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/813554209.png) ## 75. 修复BUG 有一个概率触发的bug:如果同时拾取经验和金币的时候升级,金币数量可能会更新不及时,导致虽然金币足以升级,但是升级界面不显示升级按钮。 在PlayerStatController.cs的更新函数中添加: ```csharp if(UIController.instance.levelUpPanel.activeSelf == true) { UpdateDisplay(); } ``` 这样当升级面板打开时,升级按钮可以持续不断地更新。 还有一个bug,当一个属性升级到满级时,PlayerStatController.cs中的UpdateDisplay()函数中会超出玩家数值表长度。 在PlayerStatUpgradeDisplay.cs添加满级后的显示函数: ```csharp public void ShowMaxLevel(){ valueText.text = "Max Level"; costText.text = "Max Level"; upgradeButton.SetActive(false); } ``` 修改PlayerStatController.cs中的UpdateDisplay()函数,添加判断是否超过列表长度: ```csharp public void UpdateDisplay() { if(moveSpeedLevel < moveSpeed.Count - 1) { UIController.instance.moveSpeedUpgradeDisplay.UpdateDisplay(moveSpeed[moveSpeedLevel+1].cost, moveSpeed[moveSpeedLevel].value, moveSpeed[moveSpeedLevel+1].value); } else { UIController.instance.moveSpeedUpgradeDisplay.ShowMaxLevel(); } if(healthLevel < health.Count - 1) { UIController.instance.healthUpgradeDisplay.UpdateDisplay(health[healthLevel+1].cost, health[healthLevel].value, health[healthLevel+1].value); } else { UIController.instance.healthUpgradeDisplay.ShowMaxLevel(); } if(pickupRangeLevel < pickupRange.Count - 1) { UIController.instance.pickupRangeUpgradeDisplay.UpdateDisplay(pickupRange[pickupRangeLevel+1].cost, pickupRange[pickupRangeLevel].value, pickupRange[pickupRangeLevel+1].value); } else { UIController.instance.pickupRangeUpgradeDisplay.ShowMaxLevel(); } if(maxWeaponLevel < maxWeapon.Count - 1) { UIController.instance.maxWeaponUpgradeDisplay.UpdateDisplay(maxWeapon[maxWeaponLevel+1].cost, maxWeapon[maxWeaponLevel].value, maxWeapon[maxWeaponLevel+1].value); } else { UIController.instance.maxWeaponUpgradeDisplay.ShowMaxLevel(); } } ``` ## 76. 购买升级 和74节前文所强调的问题一样,这里我们依旧使按钮的On Click()分别通过UI Controller来访问Player Stat Contoller中的Purchase()函数 在CoinController.cs中添加花费金币函数,花费金币后并且更新金币显示: ```csharp public void SpendCoins(int coinsToSpend) { currentCoins -= coinsToSpend; UIController.instance.UpdateCoins(); } ``` 在PlayerStatController.cs的`PlayerStatController`类中添加四个Purchase()函数,对应四个属性: ```csharp public void PurchaseMoveSpeed() { moveSpeedLevel++; CoinController.instance.SpendCoins(moveSpeed[moveSpeedLevel].cost); UpdateDisplay(); PlayerController.instance.moveSpeed = moveSpeed[moveSpeedLevel].value; } public void PurchaseHealth() { healthLevel++; CoinController.instance.SpendCoins(health[healthLevel].cost); UpdateDisplay(); PlayerHealthController.instance.maxHealth = health[healthLevel].value; PlayerHealthController.instance.currentHealth += health[healthLevel].value - health[healthLevel - 1].value; } public void PurchasePickupRange() { pickupRangeLevel++; CoinController.instance.SpendCoins(pickupRange[pickupRangeLevel].cost); UpdateDisplay(); PlayerController.instance.pickupRange = pickupRange[pickupRangeLevel].value; } public void PurchaseMaxWeapon() { maxWeaponLevel++; CoinController.instance.SpendCoins(maxWeapon[maxWeaponLevel].cost); UpdateDisplay(); PlayerController.instance.maxWeapons = Mathf.RoundToInt(maxWeapon[maxWeaponLevel].value); } ``` 当生命值上限增加的时候,生命值恢复生命值上限增加的数值。 最大武器上限增加时,由于`value`是浮点数,而`maxWeapon`是`int`型整数,不能直接赋值,这里需要用`Mathf.RoundToInt()`函数转换类型。 在UIController.cs中调用这些函数,购买升级后并且跳过武器升级: ```csharp public void PurchaseMoveSpeed() { PlayerStatController.instance.PurchaseMoveSpeed(); SkipLevelUp(); } public void PurchaseHealth() { PlayerStatController.instance.PurchaseHealth(); SkipLevelUp(); } public void PurchasePickupRange() { PlayerStatController.instance.PurchasePickupRange(); SkipLevelUp(); } public void PurchaseMaxWeapon() { PlayerStatController.instance.PurchaseMaxWeapon(); SkipLevelUp(); } ``` 在PlayerController.cs的开始函数中中设置移动速度,拾取范围,武器数量上限的初始值: ```csharp moveSpeed = PlayerStatController.instance.moveSpeed[0].value; pickupRange = PlayerStatController.instance.pickupRange[0].value; maxWeapons = Mathf.RoundToInt(PlayerStatController.instance.maxWeapon[0].value); ``` 在PlayerHealthController.cs的开始函数中设置玩家血量上限的初始值: ```csharp maxHealth = PlayerStatController.instance.health[0].value; ``` 进入游戏测试,点击购买升级后玩家属性获得提升。 ## 77. 计时器 计算玩家在一局游戏中存活的时间。 在场景中创建Level Manager,创建LevelManager.cs绑定到其上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class LevelManager : MonoBehaviour { public static LevelManager instance; private void Awake() { instance = this; } private bool gameAvtive; public float timer; // Start is called before the first frame update void Start() { gameAvtive = true; } // Update is called once per frame void Update() { if(gameAvtive == true) { timer += Time.deltaTime; UIController.instance.UpdateTimer(timer); } } } ``` 当gameActive为真时,timer计时,并更新UI时间的显示。 在场景中的UI Canvas下创建文本显示时间: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2529135671.png) 打开UIController.cs,添加时间文本声明`public TMP_Text timeText;`绑定刚刚创建的文本,另外添加更新时间文本的函数: ```csharp public void UpdateTimer(float time) { float minates = Mathf.FloorToInt(time / 60f); float seconds = Mathf.FloorToInt(time % 60); timeText.text = "Time: " + minutes + ":" + seconds.ToString("00"); } ``` `Seconds.ToString("00")`:Seconds以两位数的形式显示。 ![](http://cloud.nailoy.com/typecho/uploads/2024/02/305543085.png) ## 78. 结束计时 在LevelManager.cs中添加结束计时器函数: ```csharp public void EndLevel() { gameAvtive = false; } ``` 在PlayerHealthController.cs的`TakeDamge()`函数内调用此函数`LevelManager.instance.EndLevel();`,当玩家死亡后,停止计时: ```csharp public void TakeDamage(float damageToTake) { currentHealth -= damageToTake; if(currentHealth <= 0) { gameObject.SetActive(false); LevelManager.instance.EndLevel(); } healthSlider.value = currentHealth; } ``` 创建玩家死亡的粒子特效Player Death Effect,设置如下: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3074989038.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3966764382.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1689893762.png) ![效果图](http://cloud.nailoy.com/typecho/uploads/2024/02/4143054867.png) 取消勾选Looping,不循环播放,设置Stop Action为Destroy,播放完粒子动画摧毁自身。 制作粒子效果预制件。 在PlayerHealthController.cs中添加声明`public GameObject deathEffect;`绑定之前制作的预制件。 在`TakeDamge()`函数中的`LevelManager.instance.EndLevel();`后添加生成玩家死亡粒子效果`Instantiate(deathEffect, transform.position, transform.rotation);` ## 79. 游戏结束界面 在场景中创建Level End Panel作为游戏结束画面,内容如下: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3293076697.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1779448385.png) 打开UIController.cs,添加声明并和场景中的内容绑定: ```csharp public GameObject levelEndScreen; public TMP_Text endTimeText; ``` ## 80. 生效游戏结束界面 在Level Manger中改写`EndLevel()`函数: ```csharp public void EndLevel() { gameAvtive = false; float minutes = Mathf.FloorToInt(timer / 60f); float seconds = Mathf.FloorToInt(timer % 60); UIController.instance.endTimeText.text = minutes.ToString() + " mins " + seconds.ToString("00") + " scs"; UIController.instance.levelEndScreen.SetActive(true); } ``` 判定游戏结束后,记录存活时间并展示游戏结束画面。 **这里有一个问题,直接弹出结束画面,可能导致玩家不知道自己是怎么死亡的,从而降低了游戏体验。需要在玩家死亡后延迟几秒再弹出结束画面。** 添加玩家死亡后延迟时长声明`public float waitToShowEndScreen = 1f;`。 修改`EndLevel()`函数: ```csharp public void EndLevel() { gameAvtive = false; StartCoroutine(EndLevelCo()); } IEnumerator EndLevelCo() { yield return new WaitForSeconds(waitToShowEndScreen); float minutes = Mathf.FloorToInt(timer / 60f); float seconds = Mathf.FloorToInt(timer % 60); UIController.instance.endTimeText.text = minutes.ToString() + " mins " + seconds.ToString("00") + " scs"; UIController.instance.levelEndScreen.SetActive(true); }( ``` 这里使用了协程`IEnumerator`,当执行`EndLevel()`到`EndLevelCo()`时,`EndLevelCo()`函数内代码和外步代码会同步执行。 协程:调用函数时,函数将运行到完成状态,然后返回。这实际上意味着在函数中发生的任何动作都必须在单帧更新内发生;函数调用不能用于包含程序性动画或随时间推移的一系列事件。例如,假设需要逐渐减少对象的 Alpha(不透明度)值,直至对象变得完全不可见。 打开UIController.cs在顶部添加使用场景管理器的命名空间`using UnityEngine.SceneManagement;` 添加返回主菜单和重新开始游戏的函数: ```csharp public void GoToMainMenu() { } public void Restart() { SceneManager.LoadScene(SceneManager.GetActiveScene().name); } ``` `LoadScene()`:按照 Build Settings 中的名称或索引加载场景。参见 [SceneManagement.SceneManager-LoadScene - Unity 脚本 API](https://docs.unity.cn/cn/2019.4/ScriptReference/SceneManagement.SceneManager.LoadScene.html) `GetActiveScene()`:获取当前活动的场景。参见 [SceneManagement.SceneManager-GetActiveScene - Unity 脚本 API](https://docs.unity.cn/cn/current/ScriptReference/SceneManagement.SceneManager.GetActiveScene.html) 设置场景中的Main Menu和Restart的On Click()为对应的函数。 ## 81. 主菜单 创建一个新的场景命名为Main Menu,创建画布和游戏的标题。 创建新的像素字体命名为 Kenney Pixel SDF - Title 绑定到Title的字体上。 Title设置如下: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/533017019.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3735785705.png) ![效果图](http://cloud.nailoy.com/typecho/uploads/2024/02/1761243365.png) 在Canvas下创建两个按钮分别命名为Start Game和Quit Game: ![效果图](http://cloud.nailoy.com/typecho/uploads/2024/02/3100378281.png) 更改Main Camera的背景颜色为蓝色。把`.../Assets/_Udemy Vampire Survival Assets/Art`文件夹下的Gradient拖到场景中并覆盖相机的底边: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/1860840434.png) ![效果图](http://cloud.nailoy.com/typecho/uploads/2024/02/2663174273.png) 创建MainMenu.cs绑定到Canvas上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; public class MainMenu : MonoBehaviour { public string firstLevelName; public void StartGame() { SceneManager.LoadScene(firstLevelName); } public void QuitGame() { Application.Quit(); Debug.Log("I'm Quitting"); } } ``` **注意:`Application.Quit()`在Unity编辑器中不起作用,只有在游戏编译完毕后才生效,这里加入控制台输出日志反馈点击了退出游戏按钮。** 在Inspector内设置变量`firstLevelName`的值为Main。分别绑定两个按钮的On Click()为`StartGame()`和`QuitGame()`。 此时进入游戏点击Start会有报错,原因是没有在编译设置中找到Main场景。 点击File→Build Settings,添加Main和Main Menu场景到其中。**注意:右边编号为0的场景是玩家打开游戏时首先看到的场景,需要把主菜单放到0的位置:** ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3108568014.png) ## 82. 暂停菜单 ### 生效结束界面的Main Menu按钮 在UIController.cs中添加主菜单名字声明`public string mainMenuName;`并在Inspector内设置为Main Menu。 编写`GoToMainMenu()`函数的内容: ```csharp public void GoToMainMenu() { SceneManager.LoadScene(mainMenuName); } ``` ### 创建暂停界面 在UI Canvas下创建新的界面命名为Pause Screen,添加四个按钮: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/4280713864.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2852407566.png) ## 83. 生效暂停面板 先停用激活暂停面板。 在UIController.cs中添加暂停面板的声明并绑定`public GameObject pauseScreen;`。 当玩家摁下Esc键时,打开或者关闭暂停界面,在更新函数中添加: ```csharp void Update() { if(Input.GetKeyDown(KeyCode.Escape)) { PauseUnpause(); } } ``` 添加重新开始游戏,退出游戏,打开/关闭暂停界面的函数: ```csharp public void Restart() { SceneManager.LoadScene(SceneManager.GetActiveScene().name); } public void QuitGame() { Application.Quit(); } public void PauseUnpause() { if(pauseScreen.activeSelf == false) { pauseScreen.SetActive(true); Time.timeScale = 0f; } else { pauseScreen.SetActive(false); if(levelUpPanel.activeSelf == false) { Time.timeScale = 1f; } } } ``` 在Inspector内分别设置4个按钮的On Click()为对应的功能。 **此时,暂停游戏后点击Restart或者回到主菜单重新开始游戏后,画面依旧是暂停状态。这是因为Time.timeScale改变的是全局游戏的时间流速,回到主菜单或者刷新场景后时间流速仍然为0。** 在`Restart()`和`GoToMainMenu()`函数最后添加`Time.timeScale = 1f;`使时间流速恢复正常: ```csharp public void GoToMainMenu() { SceneManager.LoadScene(mainMenuName); Time.timeScale = 1f; } public void Restart() { SceneManager.LoadScene(SceneManager.GetActiveScene().name); Time.timeScale = 1f; ``` ## 84. 设置音频和混音器 打开Window→Audio→Audio Mixer命名为Main Mixer,在Groups内添加两个子项分别为Music和SFX。略微降低Music的音量。 ![](http://cloud.nailoy.com/typecho/uploads/2024/02/4255835589.png) 把`.../Assets/_Udemy Vampire Survival Assets/Audio/Music`文件夹下Assets/_Udemy Vampire Survival Assets/的音乐拖到场景中作为背景音乐,重命名为BGM,勾选Loop,设置OutPut为Music (Main Mixer): ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3436060841.png) ![](http://cloud.nailoy.com/typecho/uploads/2024/02/3040651745.png) **Unity的Mixer「混音器」不包含在特定的某一个场景中,所以可以在不同场景重复使用同一个Mixer。** 同样的在Main场景下设置背景音乐。 把`.../Assets/_Udemy Vampire Survival Assets/Audio/SFX`下的所有音效拖到Main场景中,取消勾选Play On Awake「唤醒时播放」,设置Output为SFX (Main Mixer)。 在场景中创建Empty Object命名为SFX Manager,结构如下图所示: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2293576215.png) ## 85. 播放音效 创建SFXManger.cs绑定到SFX Manager上: ```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; public class SFXManager : MonoBehaviour { public static SFXManager instance; private void Awake() { instance = this; } public AudioSource[] soundEffects; public void PlaySFX(int sfxToPlay) { soundEffects[sfxToPlay].Stop(); soundEffects[sfxToPlay].Play(); } public void PlaySFXPitched(int sfxToPlay) { soundEffects[sfxToPlay].pitch = Random.Range(.0f, 1.2f); PlaySFX(sfxToPlay); } } ``` ``` public void PlaySFX(int sfxToPlay) { soundEffects[sfxToPlay].Stop(); //先停止播放当前音效,以免声音过于杂乱 soundEffects[sfxToPlay].Play(); //播放音效 } ``` `PlaySFXPitched(int sfxToPlay)`:随机改变要播放音效的声调,让音效更富有变化。 绑定所有的音效到Sound Effects上: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2580745847.png) 在之前的C#文件中调用播放音效的函数。 ## 86. 编译游戏(终章) 打开File→Build Settings→Player Settings: ![](http://cloud.nailoy.com/typecho/uploads/2024/02/2768034457.png) 可以在这里更改游戏的一些设置,例如游戏图标,游戏名字,存档文件夹名称,分辨率设置之类。 设置完毕后,点击Build Settings中的Build按钮即可编译游戏。 **注:需要有所有Unity生成的文件以及文件夹才能运行游戏。** <div class="panel panel-default collapse-panel box-shadow-wrap-lg"><div class="panel-heading panel-collapse" data-toggle="collapse" data-target="#collapse-fab9e0c75e35730d1b6409fd8081b5ff90" aria-expanded="true"><div class="accordion-toggle"><span style="">游戏程序及代码(点击以展开)</span> <i class="pull-right fontello icon-fw fontello-angle-right"></i> </div> </div> <div class="panel-body collapse-panel-body"> <div id="collapse-fab9e0c75e35730d1b6409fd8081b5ff90" class="collapse collapse-content"><p></p> [游戏]("https://cloud.nailoy.com/typecho/uploads/2024/12/Vampire%20Survive_Game.zip") [代码]("https://cloud.nailoy.com/typecho/uploads/2024/12/Vampire%20survive_Code.zip") <p></p></div></div></div> 最后修改:2024 年 12 月 28 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 1 你的支持是我最大的动力