使⽤Unity创建塔防游戏(Part3)——项⽬总结
之前我们完成了使⽤Unity创建塔防游戏这个⼩项⽬,在这篇⽂章⾥,我们对项⽬中学习到的知识进⾏⼀次总结。
Part1的地址:
Part2的地址:
⾸先,在我们开展这个项⽬之前,必须具备Unity的基础知识,例如如何添加游戏资源和组件,理解预设体(prefabs)以及⼀些C#的编程基础。可以点击来学习这些基础知识。
不论是做2D游戏还是3D游戏,搭建好游戏场景是第⼀步,由于在⼯程中已经包含了背景和UI设置好的场景,所以我们只需要在这基础之上进⾏即可。
为Game视图设置合适的显⽰⽐例,可以保证场景中的Lable(标签)能够正确对齐。
prefab
快速创建prefab的⽅法:将游戏对象从Hierarchy视图拖拽到Project视图。
将Project视图中的prefab拖拽到场景视图中,就能以此prefab创建出⼀个游戏对象来,重复多次就能创建多个这样的对象了。
为脚本中的prefab对象赋值,将prefab从Project视图拖拽到Inspector视图。
假如我们为prefab添加了⼀个游戏组件(例如,脚本、刚体、碰撞体等),那么场景中所有以此prefab创建的对象都会拥有这个游戏组件。
快速复制prefab:传统的Ctrl + C,Ctrl + V不可⾏,Unity提供了快捷键 Ctrl + D,即Duplicate命令。选中prefab后,按下Ctrl + D即可。同理,也可以⽤于其他类型资源的复制。
项⽬中遇到的BUG:⼩怪兽的所有形态都叠在⼀起,原因:当⼀个prefab下有多个⼦sprite时,若未指定显⽰哪个⼦sprite,则当游戏对象被创建出来后,所有的sprite都会被显⽰出来。解决办法:在创建游戏对象的时候,指定要显⽰的sprite。
脚本中数据初始化
通常我们在Start() 中进⾏数据初始化,但考虑脚本中⽅法执⾏顺序的问题,有些操作必须放在Start()之前的⽅法(例如,Awake()、OnEnable() )中做。注意:这些⽅法名称的⼤⼩写必须正确,否则不会被调⽤。执⾏顺序:Awake() ——》OnEnable() ——》Start() 。
项⽬中运⽤的地⽅:脚本MonsterData属于Monster对象,在OnEnable()中初始化⼩怪兽的数据,因为OnEnable()会在Unity创建⼩怪兽的prefab时,⽴即被调⽤;Start()需要等到⼩怪兽对象作为场景的⼀部分时才会被调⽤;所以在⼩怪兽作为场景的⼀部分之前,我们需要设置好有关的数据;最终得到结论,在OnEnable()中初始化⼩怪兽的数据。
项⽬中游戏信息的共享
使⽤⼀个其他对象都能访问的共享对象来存储数据:GameManager,选择Create Empty来创建这样的⼀个游戏对象。对应的类:GameManagerBehavior,这个类⾥⾯管理的信息包括:⾦币、波数(第X波敌⼈)、游戏是否结束、玩家的⽣命值。
以⼀个public的bool 变量 gameOver来表⽰游戏是否结束,其他信息则都有各⾃对应的属性,这些属性的getter⽅法都很简单,只是返回字段的值⽽已,Setter⽅法除了设置字段的值,还做了不少其他的操作,例如设置Label的显⽰,播放相关的动画等。
C#中的属性
对应⼀个私有字段,它是对外使⽤的,在项⽬中⽤于信息的共享。
在类的内部进⾏取值操作的时候,如果没有特殊要求,尽量使⽤字段,直接取值⼀步到位。
赋值的选择:对属性赋值,还是对字段赋值?取决于我们的⽬的,是⼀次单纯的赋值,还是要调⽤Setter⽅法做更多的操作。项⽬中出现的BUG:对字段进⾏赋值,召唤⼩怪兽后,⼩怪兽所有的形态都叠在⼀起了;因为Setter⽅法中指定了⼩怪兽的当前形态。
这个项⽬中,我们⽤到的属性的getter⽅法都很简单,只是返回字段的值⽽已;setter⽅法中做的操作可以看作⼀个⼩函数。同样是扣除玩家100⾦币,gameManager.Gold -= 100; 和 gameManager.DeductPlayerGold(100); 都能做到,但很明显前者显得更简洁,我们不必为函数起名⽽烦恼了。
项⽬中⽤到的特性
1、System.Serializable
在C#中主要⽤于将⼀个对象序列化,在Unity中主要作⽤是使⼀个数据类型出现在Inspector中。这个数据类型必须是C#基本的数据类型(这⾥不只是C#,其他Unity能够识别的编程语⾔也可以,如JS),或者是Unity3D对象,另外再加上以这些可识别的对象构建的⾃定义数据类型(如类、结构体等)。注意:我们必须将访问权限设置为public。
这样做的好处——⽤于调节游戏的平衡性:我们可以在游戏运⾏时随时更改数据,并且在游戏中⽴即⽣效,停⽌运⾏后各属性⼜能恢复到最初的状态。这是Unity3D提供的⼀种运⾏时调试⽅式。
[System.Serializable]
public class MonsterLevel
{
public int cost; //召唤⼩怪兽所消耗的⾦币
public GameObject visualization; //⼩怪兽在某个特定等级的外观
public GameObject bullet;
public float fireRate;
}
Inspector中,我们可以查看MonsterLevel这个类的所有public成员,修改它们的数值。
2、HideInspector
与上⾯的System.Serializable作⽤相反,可以确保某个数据类型不会出现在Inspector中,这些数据类型往往不希望在Inspector中被修改,但仍然可以在其他脚本中访问它们。
在下⾯的代码中,HideInspector只对waypoints起作⽤,但被private修饰的currentWaypoint和lastWaypointSwitchTime也不会出现在Inspector 中。
[HideInInspector]
public GameObject[] waypoints; //所有的路标
private int currentWaypoint = 0; //敌⼈当前所在的路标
private float lastWaypointSwitchTime; //敌⼈经过路标时的时间
public float speed = 1.0f; //敌⼈的移动速度
出场率较⾼的⽅法
1、实例化游戏对象的⽅法 Static Instantiate()
它的返回值是Object类型,所以它可以克隆任何物体,包括脚本。
Instantiate(original : Object) : Object,等同于复制命令(duplicate,即Ctrl + D),只是对原物体进⾏复制,不指定position和rotation。
Instantiate(original : Object, position : Vector3, rotation : Quaternion) : Object,等同于复制命令(duplicate),对原物体进⾏复制,还指定了position和rotation。
这个⽅法有多个重载,在项⽬中,我们要选择合适的重载来完成功能。
2、获取游戏对象组件的⽅法 GetComponet(type: Type) : Componet
如果这个游戏对象包含⼀个类型为type的组件,则返回该组件;如果没有则为空。我们通过这个⽅法访问内建的组件或者脚本组件。调⽤⽅式举例:
//保持⾦币数和显⽰的同步
goldLable.GetComponet<Text>().text = "GOLD" + gold;
//播放游戏结束的动画
gameOverText.GetComponent<Animator>().SetBool("gameOver", true);
获取⼦物体组件的⽅法 GetComponetInChildren(type: Type) : Componet
返回这个游戏物体或者它的所有⼦物体上(深度优先)的类型为type的组件,只返回活动组件(Onl
y active components are returned)。调⽤⽅式举例:
monsterData = gameObject.GetComponentInChildren<MonsterData>();
3、查游戏对象组件的⽅法 static Function Find(name: string) : GameObject
Find()⽅法执⾏过程是较耗时,所以尽量不要在每⼀帧中使⽤它,例如不要在Update()中调⽤它。
为游戏对象添加标签:为敌⼈对象添加标签Enemy。在Project视图中,选中名为Enemy的prefab。在Inspector⾯板的顶部,点击Tag右边的下拉框,从弹出的对话框中选择Add Tag。
position标签属性 点击下图中的 + ,新建⼀个标签,命名为Enemy。选中Enemy prefab,将它的标签属性设置为Ene
my。
通过对游戏对象添加Tags(标签)来区别于其他游戏对象,在脚本中可以通过标签名快速查游戏对象。调⽤的⽅法:static Function FindGameObjectWithTag(name: string) : GameObject
在项⽬中是如何运⽤的:为了便于判断场景中是否还有敌⼈存在 GameObject.FindGameObjectWithTag("Enemy") == null
项⽬中的难点1:
创建塔防游戏⾥的敌⼈
1、单波敌⼈的信息
在⼤部分塔防游戏中,每⼀波敌⼈的数量、外观、能⼒都不完全相同,在⼀波敌⼈都是⼀个⼀个出现的(植物⼤战僵⼫,⼀⼤僵⼫⼀起出现)。于是我们需要配置每⼀波敌⼈的信息有:敌⼈的外观、数量、每隔多少秒出现下⼀个敌⼈。这些数据可以写在⼀个序列化的类Wave⾥⾯,这样我们可以在Inspector⾯板中更改它的数据。然后,再议Wave[] waves 这个数组来存储每⼀波敌⼈的信息。我们在Inspector⾯板中设置好waves 的长度,为数组的每⼀个元素都赋值。
2、把⼀波敌⼈创建出来
对应的脚本为 SpawnEnemy.cs。
要点:1、游戏未结束,且满⾜创建敌⼈的条件,就要不停地创建敌⼈,敌⼈是⼀个⼀个被创建出来的,所以在创建⼀个敌⼈后,必须隔spawnInterval秒才能创建下⼀个敌⼈。
2、这波敌⼈中,已被创建出来的敌⼈有多少个enemiesSpawned ;创建上⼀个敌⼈的时间 lastSpawnTime,在Start() 中将它设置为 Time.time。
3、同⼀时刻,场景中只能有⼀波敌⼈
4、给玩家留⼀些时间来准备(放置新的防御塔,升级防御塔),于是在第⼀波敌⼈出现之前或者第N波敌⼈全部被消灭时,不要马上创建第N+1波敌⼈。于是我们设置 timeBetweenWaves = 5; 5秒钟后,
才会开始出现下⼀波敌⼈。
5、当某⼀波敌⼈被全部消灭时,为创建下⼀波敌⼈做准备,再给予玩家⼀些⾦币奖励
6、若所有敌⼈都被消灭,就要播放游戏胜利的动画
实现:1、判断是否还有下⼀波敌⼈,若没有的话,游戏结束,玩家胜利; int currentWave = gameManager.Wave; if (currentWave < waves.Length)
2、创建单个敌⼈。计算出距离创建上⼀个敌⼈过去了多少时间,timeInterval = Time.time - lastSpawnTime
前提:enemiesSpawned < 这波敌⼈的总数量。只要满⾜以下两个条件之⼀,就可以创建。
条件1:已创建的敌⼈数量为0,因为要留给玩家⼀些准备时间,所以还须满⾜ timeInterval > timeBetweenWaves ,创建第1个敌⼈的时候不必考虑spawnInterval的问题。
条件2:timeInterval > spawnInterval,这个条件表⽰已经在场景中创建了X个敌⼈,且到了可以创建下⼀个敌⼈的时间。
创建出某个敌⼈后,enemiesSpawned++
3、表⽰玩家消灭了⼀波敌⼈: enemiesSpawned 等于这波敌⼈的总数量并且场景中没有⼀个敌⼈对象。
为创建下⼀波敌⼈做准备:gameManager.Wave++ enemiesSpawned = 0 lastSpawnTime = Time.time
项⽬中的难点2:
让敌⼈沿着你设定的路线移动
1、为敌⼈定义移动的路线
按照背景图中的路径,建⽴6个Waypoint路标,游戏中敌⼈是沿着直线移动的,我们将路标设置在起点、终点、4个拐点上。
如下图所⽰,起点路标是在游戏场景之外,敌⼈的初始位置是在起点路标上,终点路标在我们的饼⼲上。
2、让敌⼈沿着路线移动
这⾥我们要先设置好敌⼈的移动速度。
要点:1、敌⼈是沿着直线移动的,是⼀种缓动效果。 2、只要敌⼈没有被消灭,它们就会⼀直朝着饼⼲移动
3、敌⼈的初始位置在路标0,游戏开始不久后,敌⼈处在路标0和路标1之间;当敌⼈经过了路标1后,它的处于路标1和路标2之间。于是,我们得到结论:敌⼈所处的位置必然在 [路标X ,路标X+ 1] 这个区间⾥,我们需要记录敌⼈已经通过的路标——路标X,以及敌⼈经过此路标的时间(游戏开始时敌⼈在路标0,所以敌⼈经过路标0的时间为当前时间)。
4、当敌⼈移动后,需判断它是否抵达了终点路标。A、未抵达,则敌⼈已通过的路标变为路标X+1,敌⼈经过进过路标X+1的时间为当前时间,旋转敌⼈让敌⼈朝着饼⼲前进;B、抵达了终点路标,销毁敌⼈对象,减少玩家的⾎量。
实现:1、实现缓动效果的⽅法:Vector3.Lerp(startPosition, endPosition, currentTimeOnPath / totalTimeForPath),计算出某个时刻敌⼈所处的位置。 startPosition 路标X所在的位置,endPostion 路标X+1所在的位置;totalTimeForPath表⽰敌⼈从路标X⾛到路标X+1所需的时间;由于敌⼈在路标X的时间lastTime是已知的,所以我们可以计算出currentTimeOnPath = 当前时间
-
lastTime ; currentTimeOnPath / totalTimeForPath 就可以表⽰敌⼈⾛完路程的百分⽐。最后,Lerp返回值类型为Vector3,即为敌⼈当前所处的位置。
2、敌⼈移动的代码放在Update()中。
3、若敌⼈当前位置与终点路标的位置相同,则敌⼈抵达了最终路标。此时需要扣减玩家的⾎量,我们只需要gameManager.Health -= 1; 即可
4、当敌⼈抵达⼀个新的路标(⾮终点路标)时,旋转敌⼈,让敌⼈看起来有⽅向感。将敌⼈对象围绕Z旋转,让敌⼈沿着路线前进。此处是本项⽬中⼀个不易理解的地⽅。
A、敌⼈前进的⽅向发⽣了改变,所以我们要先计算出敌⼈新的前进⽅向。Vector3 newDirection = (newEndposition -newStartPosition); 我们要让敌⼈沿着newDirection所指的⽅向前进。
B、敌⼈要旋转的⾓度就是新的前进⽅向和旧的前进⽅向之间的夹⾓,我们要计算出这个⾓度。float rotationAngle = Mathf.Atan2(newDirection.y ,newDirection. x ) * 180 / Mathf.PI; Mathf.Atan2的返回结果是弧度,需要将它 *180 / Math.PI 转化为弧度。
C、在2D的塔防游戏中,敌⼈头顶上的⾎条都始终保持⽔平,所以敌⼈头顶上的⾎条没有必要旋转,我们只旋转敌⼈的⼦对象——Sprite。 GameObject sprite = (ansfor
m.FindChild("Sprite").gameObject; ation
= Quaternion.AngleAxis(rotationAngle , Vector3.forward);
游戏中的⽣命值
1、敌⼈头顶上的⾎条
思路:A、⽤两张图⽚来显⽰,⼀张是暗的,表⽰背景图;另⼀张是绿⾊较⼩的细长图⽚,表⽰前景图。通过缩放前景图的长度,来匹配敌⼈当前⾎量。
B、设置好两张图⽚的属性
C、为前景图添加⼀个脚本,⽤来调整它的缩放长度
如何为敌⼈添加⾎条:
A、将Enemy prefab 拖拽到场景中,现在Hierarchy视图中出现了⼀个名为Enemy的对象。
B、将Image HealthBarBackground 拖拽到Enemy对象上,作为Enemy的⼦对象。
C、将Image HealthBar 的Pivot设置为Left,因为⾎条的缩减是从右到左的;将HealthBar的X scale设置为125,把它拉长,令它的长度不⼩于HealthBarBackground
D、为HealthBar添加⼀个C#脚本,命名为HealthBar.cs
E、Enemy对象的初始位置是在场景之外的,于是需要将它的坐标设置为(20, 0, 0)
F、点击Inspector⾯板顶部的Apply按钮,保存对prefab的更改。删除Hierarchy视图中的Enemy对象。
反向思考:删掉敌⼈头顶上的⾎条?例如:将Enemy2的⾎条删掉。(实质问题:删掉prefab下的某个、某些Sprite)
选中与为敌⼈添加⾎条的过程相似:将Enemy prefab拖拽到场景中,然后依次删除Enemy对象下
的两个Sprite,最后Inspector ⾯板顶部的Apply按钮,保存对prefab的更改。删除Hierarchy视图中的Enemy对象。
不启⽤敌⼈头顶上的⾎条?
取消上图的勾勾,只是不启⽤HealthBarBackground 这个Sprite⽽已,当我们想要⽤到它的时候,勾上这个勾勾即可。不启⽤的效果如下图所⽰:
在脚本中缩放⾎条的长度
要点: A、敌⼈刚出现的时候,都是满⾎的,我们需要记录敌⼈的最⼤⽣命值、当前⽣命值、⾎条图⽚缩放的长度——X Scale。
B、在Start()⽅法中,设置⾎条图⽚缩放的长度
C、敌⼈在移动过程中遭到攻击,⾎量会减少,我们需要在Update()⽅法中缩放⾎条的长度
实现: A、⽤2个public类型的变量来记录敌⼈的最⽣命值 maxHealth 和敌⼈当前的⽣命值 currentHealth。⽤⼀个private类型的
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论