赞
踩
利⽤预制体创建物体,要使⽤实例化⽅法Instantiate()。它需要⼀个预制体的引⽤作为模版,返回值总是新创建那个物体的引⽤。如果预制体以GameObject类型传⼊,那么返回的结果也是GameObject类型。
⼩提⽰
任意物体都可以作为模版,但不⼀定是预制体
预制体的类型是GameObject。有时候由于写代码时的失误,⽤场景中的某个物体作为Instantiate()⽅法的第1个参数,同样也能成功创建新物体。
这说明在游戏运⾏以后,预制体和其他物体有着同等的地位,都可以使⽤。这种设计⼀⽅⾯增强了脚本的灵活性,另⼀⽅⾯也经常出现因混淆⽽引起的各种bug。关键是要搞清楚引⽤对象的关系。
在实际使⽤时,有时候要具体指定新建物体的位置、朝向和⽗物体,因此Instantiate()⽅法也具有多种重载形式,它们的区别在于参数不同。编者挑选了3种常⽤的重载形式进⾏说明,如表2-5所⽰。
⼩提⽰
预制体也可以⽤组件代表
如果读者在IDE⾥查看Instantiate⽅法的原型,会发现第1个参数的类型有点奇怪。Instantiate⽅法的第1个参数是预制体,理应是⼀个GameObject类型,但实际上,这个参数的类型有Object和泛型两种。
这是由于此⽅法在设计时,兼容了“⽤组件代表预制体”这⼀⽤法,前⾯提过组件也可以代表所挂载的物体。如果⽤某个预制体上挂载的组件作为模板,那么Instantiate⽅法依然会把该物体创建出来,同时返回新物体上同名的组件。这种设计虽然保持了功能不变,但少了⼀步获取组件的操作。
从学习的⾓度出发,将Instantiate看作⼀种单纯的创建物体的⽅法,有利于排除细节的⼲扰,抓住问题的本质。下⾯是⼀个创建物体的脚本范例。
- using UnityEngine;
- public class TestInstantiate : MonoBehaviour
- {
- public GameObject prefab;
- void Start()
- {
- // 在场景根节点创建物体
- GameObject objA = Instantiate(prefab, null);
- // 创建⼀个物体,作为当前脚本所在物体的⼦物体
- GameObject objB = Instantiate(prefab, transform);
- // 创建⼀个物体,指定它的位置和朝向
- GameObject objC = Instantiate(prefab, new
- Vector3(3,0,3), Quaternion.
- identity);}
- }
以上代码利⽤预制体创建了3个物体,⽽且为了获得预制体的引⽤,特地将prefab变量公开,以便在编辑器中给它赋值。先在编辑器中给prefab设置初始值,然后再运⾏脚本,就会以prefab
为模版,创建3个物体。
再举⼀个例⼦,有时需要有规则地创建⼀系列物体。例如10个物体等间距围成⼀个标准的环形,这种情况⽤编辑器拖曳是很难做到精确的,最好是⽤脚本创建它们,其代码如下。
- using UnityEngine;
- public class TestInstantiate : MonoBehaviour
- {
- public GameObject prefab;
- void Start()
- {
- // 创建10个物体围成环形
- for (int i=0; i<10; i++)
- {
- Vector3 pos = new Vector3(Mathf.Cos(i*
- (2*Mathf.PI)/10), 0,
- Mathf.Sin(i*(2*Mathf.PI)/10));
- pos *= 5; // 圆环半径是5
- Instantiate(prefab, pos, Quaternion.identity);
- }
- }
- }
为了让物体围成圆圈,上⾯的代码⽤到了圆的参数⽅程:
由于Mathf.Sin()⽅法和Mathf.Cos()⽅法的参数为弧度,因此代码中出现了Mathf.PI,它代表圆周率π。
创建组件并将其添加到物体上,通常使⽤GameObject.AddComponent()⽅法。以下代码先获取Cube物体,再给它添加Rigidbody组件。
- using UnityEngine;
- public class TestInstantiate : MonoBehaviour
- {
- void Start()
- {
- GameObject go = GameObject.Find("Cube");
- go.AddComponent<Rigidbody>();
- }
- }
AddComponent使⽤时要带上⼀个尖括号,⾥⾯写上要创建的组件的类型。这种写法与GetComponent类似,它们都利⽤了C#的泛型语法。AddComponent是GameObject类的⽅法,调⽤主体是某个游戏物体。
使⽤Destroy()⽅法可以销毁物体或组件。为了演⽰效果,下⾯编写⼀个略带互动性的例⼦。
- using UnityEngine;
- public class TestDestroy : MonoBehaviour
- {
- public GameObject prefab;
- void Start()
- {
- // 创建20个物体围成环形
- for (int i=0; i<20; i++)
- {
- Vector3 pos = new Vector3(Mathf.Cos(i*
- (2*Mathf.PI)/20), 0,
- Mathf.Sin(i*(2*Mathf.PI)/20));
- pos *= 5; // 圆环半径是5
- Instantiate(prefab, pos, Quaternion.identity);
- }
- }
- void Update()
- {
- if (Input.GetKeyDown(KeyCode.D))
- {
- GameObject cube = GameObject.Find("Cube(Clone)");
- Destroy(cu be);
- }
- }
- }
这段代码演⽰了创建物体与销毁物体,它是在之前创建物体的代码基础上修改⽽成的。运⾏游戏时,会先创建20个物体,然后每当⽤户按D键,就会删除⼀个物体。
在实践中会发现很多细节,如⽤Instantiate()⽅法创建的物体会带上“(Clone)”后缀,因此仅仅阅读书本是不够的。接下来有个问题是,如果将上⽂的代码修改如下,会导致错误吗?
- void Update()
- {
- if (Input.GetKeyDown(KeyCode.D))
- {
- GameObject cube = GameObject.Find("Cube(Clone)");Destroy(cu be);
- cube.AddComponent<Rigidbody>();
- }
- }
上述代码表⽰销毁cube之后,⼜紧接着给cube添加Rigidbody组件。⽤程序思维来考虑,这个做法是有问题的,销毁物体会导致引⽤失效,⽽使⽤失效的引⽤会导致异常。
但是通过测试,竟然发现没有错误。这是因为Unity在设计之初就考虑了引⽤失效的问题,因此在执⾏Destroy()⽅法后,并不会⽴即销毁该物体,⽽是稍后放在合适的时机去销毁。这样就保证了在当前这⼀帧⾥,对cube的操作不会产⽣错误。
在个别情况下如果有⽴即销毁的需求,Unity提供了DestroyImmediate()⽅法。在游戏开发中,代码执⾏的“时机”是⼀个根本性的难题,实践中⼤量bug背后都是时机不合适导致的。这点读者要在实践中慢慢体会。
2.3.5 定时创建和销毁物体
游戏中延迟创建物体和延迟销毁物体是常⻅的需求。
延迟创建物体⼀般⽤于等待动画结束和定时刷新怪物等。延迟销毁物体则⽤于定时让⼦弹、⼫体消失等情况,因为游戏中的物体不能只创建⽽不销毁,不然物体会越来越多,从⽽导致游戏卡顿甚⾄⽆响应。
延迟需要准确定时,如在未来的第⼏秒执⾏。在2.5节会讨论协程⽅式延迟,⽽这⾥⽤简易的Invoke()⽅法。Invoke()⽅法有两个参数,第1个参数是以字符串表⽰的⽅法名称,第2个参数表⽰延迟的时间,单位为秒。
Invoke()⽅法可以延迟调⽤⼀个⽅法,但要求该⽅法没有参数也没有返回值。
可以⽤Invoke()⽅法编写⼀个每隔0.5秒⽣成⼀个物体的动态效果,其代码如下。
- using UnityEngine;
- public class TestInvoke : MonoBehaviour
- {
- public GameObject prefab;
- int counter = 0;
- void Start()
- {
- Invoke("CreatePrefab", 0.5f);
- }
- void CreatePrefab()
- {
- Vector3 pos = new Vector3(Mathf.Cos(counter * (2 *
- Mathf.PI) / 10), 0,
- Mathf.Sin(counter * (2 * Mathf.PI) / 10));
- pos *= 5; // 圆环半径是5
- Instantiate(prefab, pos, Quaternion.identity);
- counter++;
- if (counter < 10)
- {
- Invoke("CreatePrefab", 0.5f);
- }
- }
- }
由于Invoke()⽅法不⽀持参数,因此需要⽤巧妙的⽅式实现,读者可以把它当作⼀个程序设计练习进⾏阅读。上⾯的代码依靠在被调⽤⽅法的内部再次调⽤Invoke(),模拟实现了循环过程,并⽤counter计数器限制了循环的次数。试着执⾏上⾯的代码并分析过程,有助于理解Invoke调⽤的过程。
与延迟创建物体类似,延迟销毁也同样可以⽤Invoke()实现。但延迟销毁的需求更为常⻅,因此Unity为Destroy()⽅法增添了延时的功能。Destroy()⽅法的第2个参数就可以⽤于指定销毁延迟的时间。为了试验,可以修改第2.3.4⼩节中测试程序的Update()⽅法。
- void Update()
- {
- if (Input.GetKeyDown(KeyCode.D))
- {
- GameObject cube = GameObject.Find("Cube(Clone)");
- //Destroy(cu b e);
- // 将上⼀句改为延迟0.8 秒
- Destroy(cube, 0.8f);
- cube.AddComponent<Rigidbody>();
- }
- }
这样修改,可以在按住D键0.8秒以后,销毁物体。当然这⾥只是讲解了其原理和⽤法,更好的使⽤场景⻅本章最后的完整游戏实例。
到这⾥为⽌,本书已经讲解了Unity的⼤部分核⼼功能。下⽂将再补上⼀环——脚本的⽣命周期。
脚本的⽣命周期(MonoBehaviour Lifecycle)是Unity官⽅给出的术语。实际上读者可以简单将它理解为,⼀个脚本的创建和销毁两个关键事件,以及在此过程中可能触发的各种事件。这⾥最关⼼的是所有事件的种类,以及它们的触发时机,因为脚本逻辑只有写在合适的事件⾥,且在合适的时机执⾏,才能恰到好处地实现想要的功能。Unity提供的事件⾮常多,⼤部分事件暂时⽤不到。接下来分模块、挑重点进⾏讲解。
⾸先要确定,脚本虽然功能强⼤,但它毕竟是Unity的众多系统之⼀,完全受到引擎的管理和调度。在脚本中可以编写各种功能,但什么时候脚本会被调⽤,这完全由Unity决定。
以熟悉的Start()⽅法和Update()⽅法为例。当在组件脚本中写下Update()⽅法时,就意味着向引擎“注册”了更新事件。当引擎对所有组件执⾏更新操作时,也会捎带这个脚本组件。反过来说,如果没有定义Update()⽅法,那么引擎在更新时,就会跳过这个脚本。
⼩提⽰
建议彻底删除不需要的事件⽅法默认脚本中已经写好了Start⽅法和Update⽅法。如果不需要
Update⽅法,最好将它的定义彻底删除。经过上⾯的讲解,读者应该能想到原因:只要Update存在,就算内容是空的,这个空⽩的⽅法也会被调⽤,理论上每秒会带来数⼗次调⽤⽅法的性能开销。虽然这个性能开销很⼩,但是调⽤频率较⾼且毫⽆必要。
可以把引擎每⼀帧需要做的事,想象成在标准跑道上跑⼀圈。在跑⼀圈的过程中会有很多项常规⼯作,也有⼀些突发事件需要处理。引擎允许脚本订阅它所挂载物体的各类事件,当这个事件发⽣时,引擎就会通知脚本组件,并运⾏相应的⽅法。这些事件的种类较多,⼤体上包含了初始化、物理计算、更新、渲染和析构等⽅⾯,因此编者挑选了⼀些常⽤的脚本事件,归纳在图2-29中。
为帮助读者更好地理解以上内容,再举⼀个例⼦。现在读者应该知道,脚本代码的执⾏,只是引擎整体运⾏的⼀个⼩环节。由于脚本执⾏时还占⽤着计算资源,引擎还等待着脚本执⾏完毕,因此脚本⽅法必须尽快执⾏,尽快返回。如果⽅法的执⾏时间超过了数⼗毫秒,就会引起明显的脚本卡顿,如果脚本出现了死循环等问题,就会导致整个游戏进程卡死。读者可以在Start⽅法中编写⼀个死循环做试验。
由于不能妨碍引擎的正常运⾏,因此当需要延迟或定时执⾏操作时,不能⽤死循环或休眠等⽅式,以免影响代码的执⾏。2.3.5⼩节所说的Invoke()⽅法和之后讲解的协程,都是⽤来实现延迟或定时操作的⽅式。
⼩提⽰
死循环会导致Unity程序卡死在脚本中出现死循环等情况,会导致Unity主进程卡死。如果彻
底卡死⽆响应,就只能⽤计算机操作系统的任务管理器强⾏结束任务。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。