简介
Unity的强大功能主要得益于其丰富的脚本语言。你可以使用脚本来处理用户输入、移动场景中的物体、检测碰撞、使用预制对象以及沿场景四周投射定向光线来增强你的游戏逻辑等。这听起来有点令人生畏,但由于Unity官方提供了良好的API文档支持,所以完成上述任务变得轻而易举——即使对于Unity开发新手亦然!
在本教程中,你将创建一个基于俯视角的Unity射击游戏。游戏中,你将使用Unity #脚本来生成敌人、控制玩家、发射炮弹以及实现游戏其他重要方面的控制。
【提示】本文假设你有一个C#或类似的编程语言开发经验。另外,本文示例游戏使用Unity 5.3+开发而成。
准备
首先,请下载本文示例启动项目(http://www.raywenderlich.com/wp-content/uploads/2016/03/BlockBuster.zip)并解压缩。为了在Unity中打开启动器项目,你可以从【Start Up Wizard】下单击【Open】命令,然后导航到项目文件夹。或者,您可以直接从路径【BlockBuster/Assets/Scenes】下打开文件Main.unity。
下图给出您的示例工程中场景的初始样子。
首先,请观察一下场景视图周围的情况。有一个小的场地,这将作为本示例游戏的战场;还有一部相机,当玩家在战场上走动时相机会跟随他们。如果您的布局与截图有所不同,你可以选择右上角的下拉菜单,把其中的选项改为「2 by 3」。
没有英雄存在,那算是什么游戏呢?因此,你的第一个任务是创建一个游戏对象,以表示战场中的英雄。
创建玩家对象
在【Hierarchy】中,点击【Create】按钮,然后从「3D」部分选择「Sphere」。将球体拖动到坐标位置(0,0.5,0),并将其命名为Player,如图所示。
从现在起,你将引用这一个球体作为玩家(Player)对象。
Unity使用组件系统来构建它的游戏对象;这意味着,在一个场景中的所有对象都可以通过组件的任何组合来创建。这些组合包括:用来描述一个对象位置的变换(Transform);网格过滤器(Mesh Filter),其中包含图形几何体或者任何个数的脚本(Scripts)。
玩家(Player)对象需要响应与场景中的其他对象的碰撞。
要做到这一点,请从【Hierarchy】中选择「Player」。然后,从【Inspector】选项卡下点击【Add Component】按钮。在【Physics】类别中,选择【Rigidbody】组件。这将使Player对象为Unity的物理引擎所控制。
现在,请更改Rigidody的值,如下所示:
1.Drag:1
2.Angular Drag:0
3.Constraints: Freeze Position:Y
编写玩家运动脚本
现在你有了一个Player对象。接下来,我们来编写脚本以便接收键盘输入,进而移动玩家。
在项目浏览器(Project Browser)中点击【Create】按钮,然后选择「Folder」。命名新文件夹为「Scripts」并在名为「Player」的文件夹下创建一个子文件夹。接下来,在「Player」文件夹下,点击【Create】按钮,并选择【C# Script】。命名新的脚本为PlayerMovement。顺序大致如下图所示:
【提示】Player对象将包含多个脚本,各自负责其行为的不同部分。在一个单独的文件夹下保存所有相关的脚本,使项目中文件更容易管理,并减少混乱。
现在,请双击PlayerMovement.cs脚本。在Mac上,这将打开随同Unity一起打包的MonoDevelop开发环境;在Windows上,它应该打开Visual Studio。本教程假设你使用MonoDevelop。
在类中声明下面两个公共变量:
public float acceleration;
public float maxSpeed;
其中,acceleration用于描述玩家的速度如何随时间增加,而maxSpeed代表“速度极限”。制作一个public类型的变量将会使之显示于【Inspector】之中,这样你就可以通过Unity界面来设置它的值,并根据需要调整它。
紧接着上面的声明,再声明以下变量:
private Rigidbody rigidBody;
private KeyCode[] inputKeys;
private Vector3[] directionsForKeys;
注意,私有变量无法通过【Inspector】进行设置。因此,需要由程序员在适当的时候以完全手动方式对它们进行初始化。
接下来,把Start()函数修改成如下所示的代码:
- void Start () {
- inputKeys = new KeyCode[] { KeyCode.W, KeyCode.A, KeyCode.S, KeyCode.D };
- directionsForKeys = new Vector3[] { Vector3.forward, Vector3.left, Vector3.back, Vector3.right };
- rigidBody = GetComponent<Rigidbody>();
- }
上述代码中的inputKeys数组包含了您将用于移动玩家的键码。directionsForKeys包含相应于每个键的方向;例如,按下W用于向前移动对象。至于最后一行代码,你还记得前面添加的刚体吗?这是可以得到对该组件的引用的一种方式。
要移动玩家,你就必须处理来自于键盘的输入。
现在,请重命名函数Update()为FixedUpdate(),并给它添加以下代码:
- // 1
- void FixedUpdate () {
- for (int i = 0; i < inputKeys.Length; i++){
- var key = inputKeys[i];
- // 2
- if(Input.GetKey(key)) {
- // 3
- Vector3 movement = directionsForKeys[i] * acceleration * Time.deltaTime;
- }
- }
- }
这里发生了几件重要的事情:
1.FixedUpdate()函数是帧速率独立的,在操作刚体时应该调用此函数。
2.这个循环检查是否有任何输入键被按下。
3.在这里,你得到按键的方向,并把它乘以加速度和完成最后一帧的修复所耗费的秒数。这将产生您创建一个矢量,正是使用它来移动Player对象。
注意,当您创建一个新的Unity脚本时,你实际上是创建一个新的MonoBehaviour对象。如果你熟悉iOS编程世界,那么你会知道它是一个UIViewController的等价物;也就是说,你可以使用这个对象来响应Unity内的事件,从而访问你自己的数据对象。
MonoBehaviours有很多不同的方法,它们分别对各种事件作出响应。举例来说,当MonoBehaviour实例化时如果你想初始化一些变量,那么你就可以实现方法Awake()。在MonoBehaviour被禁用时为了运行代码,你可以实现方法OnDisable()。
【提示】如果你想研究这些事件的完整列表,请访问Unity官方文档,地址是 http://docs.unity3d.com/ScriptReference/MonoBehaviour.html。
如果你是游戏编程新手,你可能会问自己,为什么必须乘以Time.deltaTime?一般的规律是,当你每隔固定的时间帧数执行一个动作时,你需要乘以Time.deltaTime。在本例情况下,你想要沿按键方向加速移动玩家,加速数值为固定的更新时间。
接下来,在方法FixedUpdate()下面添加以下方法:
- void movePlayer(Vector3 movement) {
- if(rigidBody.velocity.magnitude * acceleration > maxSpeed) {
- rigidBody.AddForce(movement * -1);
- } else {
- rigidBody.AddForce(movement);
- }
- }
上述方法用于对刚体施加力作用,使其移动。如果当前速度超过maxSpeed,力会变成相反的方向......这有点像一个速度极限。
现在,请在方法FixedUpdate()中,在if语句的结束括号之前,添加以下行:
movePlayer(movement);
很好!回到Unity中。然后,在项目浏览器中,将PlayerMovement脚本拖动到【Hierarchy】中的Player对象上。然后,使用【Inspector】来把「Acceleration」的值设置为625并把最大速度(Max Speed)修改为4375:
现在,请运行一下游戏场景,并试着使用键盘上的WASD键移动玩家对象,观察效果:
到目前,我们仅仅实现了几行代码,这已经算是一个相当不错的结果了!
然而,现在有一个明显的问题:玩家可以移出人们的视线之外,这在打坏人时是个麻烦事。
编写摄相机脚本
在「Scripts」文件夹中,创建一个名为CameraRig的新的脚本,并将其附加到主摄像机(Main Camera)上。
【提示】在选择【Scripts】文件夹情况下,点击工程浏览器中的【Create】按钮,然后选择【C# Script】。命名新的脚本为「CameraRig」。最后,把此脚本拖动到「Main Camera」对象上即可。
现在,在新创建的CameraRig类中添加下列变量:
public float moveSpeed;
public GameObject target;
private Transform rigTransform;
正如你可能已经猜到的,moveSpeed代表了相机跟踪目标的速度——这可能是场景里面的任何游戏对象。
接下来,在Start()函数中添加以下代码行:
rigTransform= this.transform.parent;
此代码获取场景层次树中的到父Camera对象的引用。场景中的每个对象具有一个变换(Transform),其中描述了一个对象的位置旋转和缩放等信息。
然后,在与上面同一个脚本文件中添加下面的方法:
- void FixedUpdate () {
- if(target == null){
- return;
- }
- rigTransform.position = Vector3.Lerp(rigTransform.position, target.transform.position,
- Time.deltaTime * moveSpeed);
- }
这部分CameraRig移动代码要比在PlayerMovement中的简单一些。这是因为你不需要一个刚体;只需要在rigTransform的位置和目标之间进行插值就足够了。
Vector3.Lerp()函数使用了空间中的两个点,还有一个界于[0,1]范围内的浮点数(它描述了沿两个端点的中间的某一点)作参数。左端点为0,右侧端点是1。于是,把0.5传递给Lerp()函数将正好返回位于两个端点中间的一个点。
这会将rigTransform移到距目标位置更近一些,而且略有缓动效果。总之,相机跟随玩家运动。
现在,返回到Unity。确保层次树(Hierarchy)中的主摄像机(Main Camera)仍处于选中状态。在【Inspector】中,把Move Speed(移动速度)设置为8,并把Target(目标)设置为Player:
再次运行游戏工程,沿场景四处移动;你会注意到,无论玩家走到哪里,相机都能够平滑地跟随目标变换。
创建敌人对象
一款没有敌人的射击游戏很容易被击败,当然也很无聊。所以,现在我们来通过单击顶部菜单中的【GameObject\3D Object\Cube】创建一个用于表示敌人的立方体对象。然后,把此立方体重命名为「Enemy」,并添加一个Rigidbody(刚体)组件。
在【Inspector】中,首先设置立方体的变换为(0,0.5,4)。并在刚体组件的「Constraints」部分的「Freeze Position」类别下勾选「Y」选择对应的复选框。
很好,现在使你的敌人气势汹汹地走动吧。然后,在【Scripts】文件夹下创建一个命名为「Enemy」的脚本。现在,你应该对这种操作很熟练了;恕不再赘述。
接下来,在类内部添加下列公共变量:
public float moveSpeed;
public int health;
public int damage;
public Transform targetTransform;
你也许可以很容易地确定出这些变量所代表的含义。你可以使用如前面一样的moveSpeed变量技巧来操纵摄像机,而且它们的效果相同。Health和damage这两个变量分别用于确定何时敌人死了以及他们死多少会伤害玩家。最后,变量targetTransform用于引用玩家对象对应的变换。
说到玩家对象,你需要创建一个类来描述敌人想破坏的所有玩家的健康值。
在项目浏览器中,选择「Player」文件夹,并创建一个名为「Player」的新脚本。这个脚本会响应于碰撞,并跟踪玩家的健康值。现在,我们通过双击此脚本来编辑它。
添加下列公共变量来保存玩家的健康值:
public int health = 3;
这样便提供了玩家健康值的默认值,但它也可以通过【Inspector】进行修改。
为了处理冲突,添加以下方法:
- void collidedWithEnemy(Enemy enemy) {
- // Enemy attack code
- if(health <= 0) {
- // Todo
- }
- }
- void OnCollisionEnter (Collision col) {
- Enemy enemy = col.collider.gameObject.GetComponent<Enemy>();
- collidedWithEnemy(enemy);
- }
当两个刚体发生碰撞时,OnCollisionEnter()即被触发。其中,Collision参数中包含了诸如接触点和冲击速度相关的信息。在本示例情况下,我们只对碰撞物体中的Enemy组件感兴趣,所以可以调用collidedWithEnemy()并执行攻击逻辑——接下来就会实现这种逻辑。
切换回文件Enemy.cs,并添加以下方法:
- void FixedUpdate () {
- if(targetTransform != null) {
- this.transform.position = Vector3.MoveTowards(this.transform.position, targetTransform.transform.position, Time.deltaTime * moveSpeed);
- }
- }
- public void TakeDamage(int damage) {
- health -= damage;
- if(health <= 0) {
- Destroy(this.gameObject);
- }
- }
- public void Attack(Player player) {
- player.health -= this.damage;
- Destroy(this.gameObject);
- }
你已经熟悉了FixedUpdate()函数,略有不同的是现在使用的是MoveTowards()而不是Lerp()函数。这是因为敌人应该一直以相同的速度移动而不会在接近目标时出现快速移动。当敌人被弹丸击中时,TakeDamage()即被调用;当敌人到达值为0的健康值时他会自我毁灭。Attack()函数的实现逻辑是与之很类似的——对玩家进行伤害,然后敌人破坏自身。
切换回Player.cs。然后,在函数collidedWithEnemy()中,使用下面代码替换注释// Enemy attack code:
enemy.Attack(this);
游戏中,玩家将受到伤害,而敌人在该过程中将自我毁灭。
切换回Unity。把Enemy脚本附加到 Enemy对象上;并在【Inspector】中,针对Enemy对象设置以下值:
1.Move Speed:5
2.Health:2
3.Damage:1
4.Target Transform:Player
现在,你应该能够自己做这一切了。结束后,你可以与文后完整的工程源码进行比较。
在游戏中,敌人与玩家碰撞,从而实现一种有效的敌对攻击。使用Unity的物理碰撞检测几乎是一个很简单的任务。
最后,在层次结构中把Player脚本附加到Player对象。
运行游戏工程,并留意在控制台上输出的结果:
当敌人接触到玩家时,它能够成功地进行攻击,并把玩家的健康值变量降低到2。但是,现在在控制台中抛出一个NullReferenceException异常,错误指向Player脚本:
哈哈,现在玩家不仅可以与敌人碰撞,也可能与游戏世界中的其他部分,如战场,发生碰撞!这些游戏对象并没有Enemy脚本,因此GetComponent()函数将返回null。
接下来,打开文件Player.cs。然后,在OnCollisionEnter()函数中,把collidedWithEnemy()函数调用使用一个if语句包括起来,如下所示:
- if(enemy) {
- collidedWithEnemy(enemy);
- }
此时,异常消失!
使用预制
只是简单地在战场上跑来跑去,而且避开敌人;这只能算是一个一边倒的游戏。现在,我们来武装一下玩家,使之能够作战。
单击层次结构中的【Create】按钮,并选择【3D Object/Capsule】。命名它为Projectile,并给它指定下列变换值:
1. Position:(0, 0, 0)
2. Rotation:(90, 0, 0)
3. Scale:(0.075, 0.246, 0.075)
每当玩家射击时,他就会发射Projectile(炮弹)的一个实例。要做到这一点,你需要创建一个预制(Prefab)。不像场景中你已经拥有的其他对象,预制对象是根据游戏逻辑需要而创建的。
现在,在文件夹「Assets」下创建一个新的文件夹,名为Prefabs。现在,把Projectile对象拖动到这个文件夹上。就是这样:你创建了一个预制!
您的预制还需要一点脚本。现在,在【Scripts】文件夹内创建一个名为「Projectile」的新脚本,并添加下面的类变量:
public float speed;
public int damage;
Vector3 shootDirection;
就像目前为止在本教程中任何可移动的物体一样,这个对象也会有速度和伤害对应的变量,因为它是战斗逻辑的一部分。其中,shootDirection矢量决定了炮弹将向哪儿发射。
在类中实现下面的方法即可使这个矢量发挥作用:
- // 1
- void FixedUpdate () {
- this.transform.Translate(shootDirection * speed, Space.World);
- }
- // 2
- public void FireProjectile(Ray shootRay) {
- this.shootDirection = shootRay.direction;
- this.transform.position = shootRay.origin;
- }
- // 3
- void OnCollisionEnter (Collision col) {
- Enemy enemy = col.collider.gameObject.GetComponent<Enemy>();
- if(enemy) {
- enemy.TakeDamage(damage);
- }
- Destroy(this.gameObject);
- }
在上面的代码中发生了下面的事情:
1.炮弹在游戏中的运动方式与其他对象不同。它不具有一个目标,或者一直对它施加一些力;相反,它在其整个生命周期中的按照预定方向进行运动。
2.在这里,我们设置了预制对象的起始位置和方向。Ray参数看上去似乎很神秘吧,但你很快就会知道它是如何计算出来的。
3.如果一个炮弹与敌人发生碰撞,它会调用TakeDamage(),并进行自我毁灭。
在场景层次中,把Projectile脚本附加到Projectile游戏对象上。设置它的速度为0.2,并把损坏值设置为1,然后点击【Inspector】顶部的【Apply】按钮。这将针对这个预制的所有实例保存刚才所做的更改。
现在,请从场景层次树中删除Projectile对象,因为我们不再需要它了。
发射炮弹
现在,你既然已经拥有了可以移动并施加伤害能力的预制对象,那么,接下来你就可以开始考虑实现发射炮弹相关的编程了。
在Player文件夹下,创建一个名为PlayerShooting的新脚本,并将其附加到场景中的Player游戏对象。然后,在Player类中,声明以下变量:
public Projectile projectilePrefab;
public LayerMask mask;
第一个变量将包含对前面创建的Projectile预制对象的引用。每当玩家发射炮弹时,您将从这个预制创建一个新的实例。mask变量是用来筛选游戏对象(GameObject)的。
现在,我们要介绍一下光线投射的问题。何谓光线投射(casting Ray)?这是什么魔法?
其实,并不存在什么黑魔法。但是,有时候在你的游戏中,你的确需要知道是否在一个特定方向上存在碰撞。要做到这一点,Unity在您指定的方向上能够从某一个点投出一条看不见的射线。你可能会遇到很多与射线相交的游戏对象;因此,使用筛选器可以过滤掉任何不需要参与碰撞的对象。
光线投射是非常有用的,并且可以用于各种用途。它们常用于测试是否另一名玩家已经被炮弹击中;而且,你也可以使用它们来测试是否在鼠标指针下方存在任何的几何形状。要更多地了解关于光线投射的内容,请参考一下Unity官方网站提供的在线培训视频(https://unity3d.com/learn/tutorials/modules/beginner/physics/raycasting)。
下图显示了从一个立方体到一个锥体的光线投射情况。由于射线上有一个图标掩码,因此它忽略掉游戏对象而系统给出的提示是击中了锥体:
接下来,我们需要创建自己的射线了。
把如下代码添加到文件PlayerShooting.cs:
- void shoot(RaycastHit hit){
- // 1
- var projectile = Instantiate(projectilePrefab).GetComponent<Projectile>();
- // 2
- var pointAboveFloor = hit.point + new Vector3(0, this.transform.position.y, 0);
- // 3
- var direction = pointAboveFloor - transform.position;
- // 4
- var shootRay = new Ray(this.transform.position, direction);
- Debug.DrawRay(shootRay.origin, shootRay.direction * 100.1f, Color.green, 2);
- // 5
- Physics.IgnoreCollision(GetComponent<Collider>(), projectile.GetComponent<Collider>());
- // 6
- projectile.FireProjectile(shootRay);
- }
概括来看,上面的代码主要实现如下功能:
1. 实例化一个炮弹预制并获得它的Projectile组件,从而可以把它初始化。
2. 这个坐标点总是使用像(X,0.5,Z)这样的格式。其中,X和Z坐标位于地面上,正好对应于射线投射击中的鼠标点击位置的坐标。这里的计算是很重要的,因为炮弹必须平行于地面;否则,你会向下射击,而只有外行的玩家才会出现向地面射击的情况。
3. 计算从游戏物体Player指向pointAboveFloor的方向。
4. 创建一条新的射线,并通过其原点和方向来共同描述炮弹轨迹。
5. 这行代码告诉Unity的物理引擎忽略玩家与炮弹之间的碰撞。否则,在炮弹飞出去前将调用Projectile脚本中的OnCollisionEnter()方法。
6. 最后,设置炮弹的运动轨迹。
【注意】当光线投射不可见时,你可以使用Debug.DrawRay()方法来辅助调试程序,因为它可以帮助您更直观地观察光线的外观和它所击中的对象。
好,现在既然发射炮弹的逻辑已经实现,请继续添加下面的方法来让玩家真正扣动扳机:
- // 1
- void raycastOnMouseClick () {
- RaycastHit hit;
- Ray rayToFloor = Camera.main.ScreenPointToRay(Input.mousePosition);
- Debug.DrawRay(rayToFloor.origin, rayToFloor.direction * 100.1f, Color.red, 2);
- if(Physics.Raycast(rayToFloor, out hit, 100.0f, mask, QueryTriggerInteraction.Collide)) {
- shoot(hit);
- }
- }
- // 2
- void Update () {
- bool mouseButtonDown = Input.GetMouseButtonDown(0);
- if(mouseButtonDown) {
- raycastOnMouseClick();
- }
- }
让我们按上面编号进行逐个解释:
1.这个方法把射线从摄相机射向鼠标点击的位置,然后检查是否射线相交于符合给定LayerMask掩码值的游戏对象。
2.在每次更新中,脚本都会检查一下鼠标左键按下情况。如果发现存在按下的情况,就调用raycastOnMouseClick()方法。
现在,请返回到Unity中,并在【Inspector】中设置下列变量:
Projectile Prefab:引用文件夹prefab下的Projectile;
Mask:Floor
【注意】Unity使用数量有限的预定义掩码——也称为层。
你可以通过点击一个游戏物体的【Layer】下拉菜单然后选择【Add Layer】(添加图层)来定义你自己的掩码:
您也可以通过从【Layer】下拉菜单中选择一个层来给游戏对象分配掩码:
有关Unity3d引擎中层的更多的信息,请参考官方文档,地址是http://docs.unity3d.com/Manual/Layers.html。
现在,请运行示例项目并随意发射炮弹!你会注意到:炮弹按照希望的方向发射,但看起来还缺少点什么,不是吗?
如果炮弹是沿着其发射的方向行进的,那将酷多了。为了解决这个问题,打开Projectile.cs脚本并添加下面的方法:
- void rotateInShootDirection() {
- Vector3 newRotation = Vector3.RotateTowards(transform.forward, shootDirection, 0.01f, 0.0f);
- transform.rotation = Quaternion.LookRotation(newRotation);
【注意】RotateTowards非常类似于MoveTowards,但它把矢量作为方向,而不是位置。此外,你并不需要一直改变旋转;因此,使用一个接近零的步长值就足够了。在Unity中实现旋转变换是使用四元组实现的,这已超出了本教程的讨论范围。在本教程中,你只需要知道在涉及三维旋转计算时使用四元组的优势超过矢量即可。当然,如果你有兴趣更多地了解关于四元组以及它们有何用处,请参考这篇优秀的文章,地址是http://developerblog.myo.com/quaternions/。
接下来,在FireProjectile()方法的结束处,添加对rotateInShootDirection()方法的调用。 现在,FireProjectile()方法看起来应该像下面这样:
- public void FireProjectile(Ray shootRay) {
- this.shootDirection = shootRay.direction;
- this.transform.position = shootRay.origin;
- rotateInShootDirection();
- }
再次运行游戏,并沿几个不同的方向发射炮弹。此时,炮弹将指向它们发射的方向。现在,你可以清除代码中的Debug.DrawRay调用了,因为你不再需要它们了。
生成更多敌人对象
只有一个敌人的游戏并不具有挑战性。但现在,你已经知道了预制的用法。于是,你可以生成任意数目的对手了!
为了让玩家不断猜想,你可以随机地控制每个敌人的健康值、速度和位置等。
现在,使用命令【GameObject】-【Create Empty】创建一个空的游戏对象。命名它为「EnemyProducer」,并添加一个Box碰撞器组件。最后,在【Inspector】设置其值如下:
1. Position:(0, 0, 0)
2. Box Collider:
3. Is Trigger:true
4. Center:(0, 0.5, 0)
5. Size:(29, 1, 29)
上面你附加的这个碰撞器实际上在战场中定义了一个特定的3D空间。为了看到这个对象,请从层次结构树下选择【Enemy Producer】游戏物体;于是,在场景视图中你会看到这个对象,如下图所示。
图中用绿线框出的部分代表了一个碰撞器
现在,你要编写一个脚本实现沿X轴和Z轴方向选取空间中的一个随机位置并实例化一个敌人预制。
创建一个名为EnemyProducer的新脚本,并将其附加到游戏对象EnemyProducer。然后,在新设置的类内部,添加以下实例成员:
public bool shouldSpawn;
public Enemy[] enemyPrefabs;
public float[] moveSpeedRange;
public int[] healthRange;
private Bounds spawnArea;
private GameObject player;
第一个变量控制启用还是禁用敌人对象的生成。该脚本将从enemyPrefabs中选择一个随机的敌人预制并创建其实例。接下来的两个数组将分别指定速度和健康值的最小值和最大值。生成敌人的地方是你在场景视图中看到的绿色框。最后,你需要一个到玩家Player的引用,并把它作为目标参数传递给敌人对象。
在脚本中,接着定义以下方法:
- public void SpawnEnemies(bool shouldSpawn) {
- if(shouldSpawn) {
- player = GameObject.FindGameObjectWithTag("Player");
- }
- this.shouldSpawn = shouldSpawn;
- }
- void Start () {
- spawnArea = this.GetComponent<BoxCollider>().bounds;
- SpawnEnemies(shouldSpawn);
- InvokeRepeating("spawnEnemy", 0.5f, 1.0f);
- }
SpawnEnemies()方法获取到标签为Player的游戏对象的引用,并确定是否应该生成一个敌人。
Start()方法初始化敌人生成的位置并在游戏开始0.5秒之后调用一个方法。每一秒它都会被反复调用。除了作为一个setter方法外,SpawnEnemies()方法还得到一个到标签为「Player」的游戏对象的引用。
注意,到现在为止,玩家游戏对象尚未标记。现在,就要做这件事情。请从【Hierarchy】中选择Player对象,然后在【Inspector】选项卡中从「Tag」下拉菜单中选择Player,如下图所示。
现在,你需要编写实际的生成单个敌人的代码。
打开Enemy脚本,并添加下面的方法:
- public void Initialize(Transform target, float moveSpeed, int health) {
- this.targetTransform = target;
- this.moveSpeed = moveSpeed;
- this.health = health;
- }
这个方法充当用于创建对象的setter方法。下一步:要编写生成成群的敌人的代码。打开EnemyProducer.cs文件,并添加以下方法:
- Vector3 randomSpawnPosition() {
- float x = Random.Range(spawnArea.min.x, spawnArea.max.x);
- float z = Random.Range(spawnArea.min.z, spawnArea.max.z);
- float y = 0.5f;
- return new Vector3(x, y, z);
- }
- void spawnEnemy() {
- if(shouldSpawn == false || player == null) {
- return;
- }
- int index = Random.Range(0, enemyPrefabs.Length);
- var newEnemy = Instantiate(enemyPrefabs[index], randomSpawnPosition(), Quaternion.identity) as Enemy;
- newEnemy.Initialize(player.transform,
- Random.Range(moveSpeedRange[0], moveSpeedRange[1]),
- Random.Range(healthRange[0], healthRange[1]));
- }
这个spawnEnemy()方法所做的就是选择一个随机的敌人预制,在随机位置实例化并初始化脚本Enemy中的公共变量。
现在,脚本EnemyProducer.cs快要准备好了!
返回到Unity中。通过把Enemy对象从【Hierarchy】拖动到【Prefabs】文件夹创建一个Enemy预制。然后,从场景中移除Enemy对象——你不需要它了。接下来,设置Enemy Producer脚本中的公共变量:
1. Should Spawn:True
2. Enemy Prefabs:
Size:1
Element 0:引用敌人预制
3. Move Speed Range:
Size:2
Element 0:3
Element 1:8
4. Health Range:
Size:2
Element 0:2
Element 1:6
现在,运行游戏并注意观察。你会注意到场景中无休止地出现成群的敌人!
好吧,这些立方体看起来还不算非常可怕。现在,我们再来添加一些细节修饰。
在场景中创建一个三维圆柱(Cylinder)和一个胶囊(Capsule)。分别命名为「Enemy2」和「Enemy3」。就像前面你针对第一个敌人所做的那样,向这两个对象分别都添加一个刚体组件和一个Enemy脚本。然后,选择Enemy2,并在【Inspector】中像下面这样更改它的配置:
1. Scale:(0, 0.5, 0)
2. Rigidbody:
Use Gravity:False
Freeze Position:Y
Freeze Rotation:X, Y, Z
3. Enemy Component:
Move Speed: 5
Health: 2
Damage: 1
Target Transform: None
现在,针对Enemy3也进行与上面同样的设置,但是把它的Scale设置成0.7,如下图所示。
接下来,把他们转换成预制,就像你操作最开始的那个敌人那样,并在「Enemy Producer」中引用它们。在【Inspector】中的值应该像下面这样:
Enemy Prefabs:
Size: 3
Element 0: Enemy
Element 1: Enemy2
Element 2: Enemy3
再次运行游戏;现在,你会观察到在场景中生成不同的预制。
其实,在你意识到你是不可战胜的之前,不会花费太长的时间!
开发游戏控制器
现在,您已经能够射击、移动,而且能够把敌人放在指定位置。在本节中,你将实现一个基本的游戏控制器。一旦玩家“死”了,它将重新启动游戏。但首先,你必须建立一种机制以通知所有有关各方——玩家已达到0健康值。
现在,打开Player脚本,并在类声明上方添加如下内容:
using System;
然后,在类中添加以下新的公共事件:
public event Action<Player> onPlayerDeath;
【提示】事件是C#语言中的重要功能之一,让你向所有监听者广播对象中的变化。要了解如何使用事件,你可以参考一下官方的事件培训视频(https://unity3d.com/learn/tutorials/topics/scripting/events)。
接下来,编辑collidedWithEnemy()方法,使之最终看起来具有像下面这样的代码:
- void collidedWithEnemy(Enemy enemy) {
- enemy.Attack(this);
- if(health <= 0) {
- if(onPlayerDeath != null) {
- onPlayerDeath(this);
- }
- }
事件为对象之间的状态变化通知提供了一种整洁的实现方案。游戏控制器对上述声明的事件是很感兴趣的。在Scripts文件夹中,创建一个名为GameController的新脚本。然后,双击该文件进行编辑,并给它添加下列变量:
public EnemyProducer enemyProducer;
public GameObject playerPrefab;
脚本在生成敌人时需要进行一定的控制,因为一旦玩家丧生再生成敌人是没有任何意义的。此外,重新启动游戏意味着你将不得不重新创建玩家,这意味着……是的,你要通过把玩家变成预制来更灵活地实现这一目的。
于是,请添加下列方法:
- void Start () {
- var player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>();
- player.onPlayerDeath += onPlayerDeath;
- }
- void onPlayerDeath(Player player) {
- enemyProducer.SpawnEnemies(false);
- Destroy(player.gameObject);
- Invoke("restartGame", 3);
- }
在Start()方法中,该脚本先获取到Player脚本的引用,并订阅你先前创建的事件。一旦玩家的健康值达到0, onPlayerDeath()方法即被调用,从而停止敌人的生成,从场景中移除Player对象和并在3秒钟后调用restartGame()方法。
最后,重新启动游戏的动作实现如下:
- void restartGame() {
- var enemies = GameObject.FindGameObjectsWithTag("Enemy");
- foreach (var enemy in enemies)
- {
- Destroy(enemy);
- }
- var playerObject = Instantiate(playerPrefab, new Vector3(0, 0.5f, 0), Quaternion.identity) as GameObject;
- var cameraRig = Camera.main.GetComponent<CameraRig>();
- cameraRig.target = playerObject;
- enemyProducer.SpawnEnemies(true);
- playerObject.GetComponent<Player>().onPlayerDeath += onPlayerDeath;
在这里,我们做了一些清理工作:摧毁场景中的所有敌人,并创建一个新的Player对象。然后,重新指定摄像机的目标为玩家对象,恢复敌人生成支持,并为游戏控制器订阅玩家死亡的事件。
现在返回到Unity,打开Prefebs文件夹,更改所有敌人预制为标签Enemy。接下来,通过拖动Player游戏对象到Prefebs文件夹使玩家变成预制。再创建一个空的游戏对象,将其命名为GameController,并将您刚刚创建的脚本附加到其上。绑定【Inspector】中所有对应的需要的引用。
现在,你应该很熟悉这种模式了。建议你试着自己实现引用,再次运行游戏。请观察游戏控制器是如何实现游戏控制的。
故事至此结束;你已经成功地使用脚本实现了你的第一个Unity游戏!祝贺你!
小结
本文示例工程完整的下载地址是http://www.raywenderlich.com/wp-content/uploads/2016/03/BlockBusterFinal.zip。
现在,你应该对编写一个简单的动作游戏所需要的内容有了一个很好的理解。实际上,制作游戏决不是一个简单的任务;它肯定需要大量的工作,而脚本只是把一个项目实现为一款真正的游戏所必需的要素之一。为了进一步添加游戏修饰效果,还需要将动画和漂亮的UI及粒子效果等添加到您的游戏中。当然,要实现一款真正意义上的商业游戏,您还要克服更多的困难。