Booom2023复盘
首先是一些最基础的代码诀窍,这些方法几乎在任何游戏中都能使用,可以说是一位工程师“入门”级别的诀窍。
ObjectPool
试想一下短时间内生成100个敌人的无双游戏?又或者是需要在造成伤害后跳出满屏幕的伤害数字?
这个时候该怎么办,Instantiate预制体几乎是唯一的实现方式,这个时候可能要做的就是——短时间内Instantiate一百甚至一千多个预制体对象。
但是这对Unity甚至你的电脑是一个毁灭性的打击,文件载入类型的操作要占用大量性能,无所作为地使用这种方式来初始化对象的前提是你的玩家并不在乎几乎永无休止的卡顿。
对象池思想并不是用了“另一种初始化”的路子,他只是单纯地将对象的初始化全部放在了进入游戏场景的那一瞬间,也就是说把玩家在游玩过程中的卡顿“放”在了载入场景的时候,并将其”失活“,在实际需要的时候再将其“激活”。
[SerializeField] private GameObject TestEnemy;
[SerializeField] private int initMinEnemy = 100;//初始化的对象数量
static ObjectPool instance;//一个游戏中的对象池往往只需要一个,所以设置为单例方便引用
Queue<GameObject> queue = new Queue<GameObject>();//对象池中的对象用队列存起(也可以用栈或者其他)
void Awake(){
//Initialize()
for (int i = 0; i < initMinEnemy; i++)
{
GameObject tmp;
tmp = Instantiate(instance.TestEnemy);
tmp.SetActive(false);
queue.Enqueue(tmp);
}
//
}
public static Gameobject GetObject(){
if(instance.queue.Count>0){
return instance.queue.Dequeue();
}
else{
GameObject tmp;
tmp = Instantiate(instance.TestEnemy);
tmp.SetActive(false);
}//如果队列中不够,则直接重新初始化
}
public static void ReturnObject(GameObject deadEnemy)
{
instance.queue.Enqueue(deadEnemy);
}//对象销毁的时候切勿销毁,请先归还回去——如果有条件可以多写“如队列满则直接销毁”
这样一来一个基础的对象池就搭建完成了,当然,可以根据需要进行需求加持,例如需要生成不同种类的敌人时,需要使用不同的队列来进行存储,然后不同的队列又可以通过字典来存储。
[SerializeField] private GameObject TestEnemy;
[SerializeField] private GameObject Lost;
...
Dictionary<string, Queue<GameObject>> poolingDict = new Dictionary<string, Queue<GameObject>>();
void Initialize(){
...
foreach (CharacterData_SO.CharacterType characterType in Enum.GetValues(typeof(CharacterData_SO.CharacterType)))
{
Queue<GameObject> tmp = new Queue<GameObject>();
for (int i = 0; i < initMinEnemy; i++)
{
tmp.Enqueue(CreateObject(characterType));
}
poolingDict.Add(characterType.ToString(), tmp);
}
foreach (BulletData_SO.Type bulletType in Enum.GetValues(typeof(BulletData_SO.Type)))
{
Queue<GameObject> tmp = new Queue<GameObject>();
for (int i = 0; i < initMinBullet; i++)
{
tmp.Enqueue(CreateObject(bulletType));
}
poolingDict.Add(bulletType.ToString(), tmp);
}
...
}
private static GameObject CreateObject<T>(T type)
{
GameObject tmp;
switch (type)
{
default:
case CharacterData_SO.CharacterType.Monster:
tmp = Instantiate(instance.TestEnemy);
break;
case CharacterData_SO.CharacterType.Lost:
tmp = Instantiate(instance.Lost);
break;
}
tmp.transform.parent = instance.transform;
tmp.SetActive(false);
return tmp;
}
…
对象池可容纳的只有敌人吗?掉落在地上的道具、子弹的攻击特效、甚至NPC本身都可以是对象池的容纳品,只要是需要进行“实例化”的操作,都可以放入对象池进行优化,然后统一在加载界面进行归一管理。
异步的加载界面
前面提到过将所有“实例化”都推到场景加载界面,那么你会如何进行场景加载呢,是类似MC直接将对象一个个实例化摆在玩家面前(实际上MC对象的实例化在加载界面中而地图的加载才是直接进行的)?还是给出一个黑幕不动的图片?
大部分游戏的解决办法是显示上给出加载界面,后台进行场景的加载,这里我的方案是进行加载场景的加载,然后异步进行“真场景”的加载,以此来进行过渡。
private static SceneManager instance;//建议单独编写场景管理脚本单例
public static SceneManager Instance
{
get
{
if (instance == null)
{
instance = FindObjectOfType<SceneManager>();
if (instance == null)
{
GameObject manager = new GameObject("SceneManager");
instance = manager.AddComponent<SceneManager>();
}
}
return instance;
}
}//这一段其实不必理会。。。
public void LoadScene(string sceneName)
{
UnityEngine.SceneManagement.SceneManager.LoadScene(sceneName);
}//正常的场景切换
public float minimumLoadingTime = 2f;
public string sceneName; // 要加载的场景名称
public void LoadSceneAsync()
{
StartCoroutine(LoadSceneAsyncCoroutine());
}
private IEnumerator LoadSceneAsyncCoroutine()
{
float startTime = Time.time;
// 异步加载目标场景
AsyncOperation asyncOperation = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(sceneName);
yield return new WaitForSeconds(minimumLoadingTime);
// 等待加载完成
while (!asyncOperation.isDone)
{
yield return null;
}
}//同时动用了协程,这里之后马上就要讲到
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}//场景管理器不要随着场景变换而消失,这一点比对象池更严格
- 场景管理器打包了异步变化场景的所有方法,在其他任何地方可以直接调用函数来进行场景切换
在其他任何想要进行场景切换的地方如下编写:
SceneManager.Instance.LoadScene("Pass");//“Pass”为过渡场景的名字
SceneManager.Instance.sceneName = "main";//“main”改为目的真场景
SceneManager.Instance.LoadSceneAsync();
当然可以根据个人码风进行合理魔改。
minimumLoadingTime
设置了最低加载时间,特别是小体量的场景,异步的真场景会被瞬间加载完成
这也是使用协程的目的之一,试想游戏加载过程中会不会出现先加载好的角色可以移动,但加载动画还在进行、加载太快导致加载动画一瞬间就结束了。
yield return new WaitForSeconds(minimumLoadingTime);
暂停协程,给玩家一个加载过程的展示时间
协程
Unity为了降低门槛,设计之初就是单线程工作。所以Unity并没有为多线程操作提供过多的API。
与之相反,Unity提供了协程来模仿多线程以实现异步操作,本质上其实还是单线程。
也不是不能用多线程,但是使用多线程已经和unity关系不大,这里的多线程已经是C#的本质特性了。
和本次booom关系更不大,但是协程的应用场景还是非常多的。
IEnumerator
协程是通过迭代器来实现的,IEnumerator用来定义一个迭代方法。
StartCoroutine(string methodName)//无参数的情况
StartCoroutine(IEnumerator routine)//通过方法形式调用
StartCoroutine(string methodName,object values)//带参数的通过方法名进行调用
StopCoroutine(string methodName)//通过方法名(字符串)来进行
StopCoroutine(IEnumerator routine)//通过方法形式来调用
StopCoroutine(Coroutine routine)//通过指定的协程来关闭
yield
一个语法糖,也是Unity生命周期的一些执行方法。
yield return null; //暂停协程等待下一帧继续执行
yield return 0//或其他数字; //暂停协程等待下一帧继续执行
yield return new WairForSeconds(时间); //等待规定时间后继续执行
yield return StartCoroutine("协程方法名");//开启一个协程(嵌套协程)
以上执行顺序位于Update
与LateUpdate
之间
yield return GameObject; //当游戏对象被获取到之后执行
yield return new WaitForFixedUpdate();//等到下一个固定帧数更新
yield return new WaitForEndOfFrame();//等到所有相机画面被渲染完毕后更新
yield break; //跳出协程对应方法,其后面的代码不会被执行
协程用简单的话说,就是“将这一帧来不及完成的任务,交给下一帧进行”,例如下面的代码:
public List<int>nums = new List<int>{1,2,3,4,5};
private void Start()
{
StartCoroutine(PrintNum(nums));
}
//通过协程分帧处理
IEnumerator PrintNum(List<int> nums)
{
foreach(int i in nums)
{
Debug.Log(i);
yield return null;
}
}
yield return null
的目的就是读取完一个数之后缓一帧
这样一来,理解上面的异步加载场景就十分简单了,协程最主要的作用,其实就是控制“场景在加载完之前不进行跳转”,而且更方便设置“最小加载时间”。
协程实现的文字淡入淡出
协程一般用于优化进程,减少卡顿协助处理,这里的淡入淡出功能也可以单纯使用计数器来完成,但使用协程更方便些。
public class TextFade : MonoBehaviour
{
[SerializeField] private HunterWhatSay _say;
public Text textComponent;
public float fadeInDuration = 1f;
public float fadeOutDuration = 1f;
public void StartText()
{
RandomArr(_say._str);
textComponent.text = _say._str[0];
textComponent.gameObject.SetActive(true);
StartCoroutine(FadeInAndOut());
}
public void EndText()
{
StopCoroutine(FadeInAndOut());
textComponent.gameObject.SetActive(false);
}
public void Endtext()
{
textComponent.gameObject.SetActive(false);
}
private System.Collections.IEnumerator FadeInAndOut()
{
Color originalColor = textComponent.color;
Color transparentColor = new Color(originalColor.r, originalColor.g, originalColor.b, 0f);
//淡入
float t = 0f;
while (t < fadeInDuration)
{
t += Time.deltaTime;
float normalizedTime = t / fadeInDuration;
textComponent.color = Color.Lerp(transparentColor, originalColor, normalizedTime);
yield return null;
}
yield return new WaitForSeconds(2f);
//淡出
t = 0f;
while (t < fadeOutDuration)
{
t += Time.deltaTime;
float normalizedTime = t / fadeOutDuration;
textComponent.color = Color.Lerp(originalColor, transparentColor, normalizedTime);
yield return null;
}
textComponent.gameObject.SetActive(false);
}
static void RandomArr<T>(T[] arr)
{
System.Random r = new System.Random();
for (int i = 0; i < arr.Length; i++)
{
int index = r.Next(arr.Length);
T temp = arr[i];
arr[i] = arr[index];
arr[index] = temp;
}
}
}
- 可以看出协程很方便地完成了“在一定时间内淡入”、“静止一定时间后开始淡出”、“在一定时间内淡出”三个功能,而且这些功能不影响主线程的工作。
跟随Object的文字
总所周知Unity的UGUI与世界用的不是一套坐标系。
所以理论上UGUI中的所有组件在世界中都无法展示。
唯一实现动态文字跟随的办法就是在UI中打出文字,并让他强行跟随世界坐标人物。
用我们的眼睛看很简单,但在程序中略微麻烦,需要进行一套坐标转换。可以直接使用下面的脚本:
using UnityEngine;
using System.Collections;
public class HandUI : MonoBehaviour
{
public Transform Hand;//文字对应的物体
private void Update()
{
Vector3 pos = new Vector3();
pos = Camera.main.WorldToScreenPoint(Hand.position);
transform.position = pos;
}
}