前言
在Unity官方教程 2D Roguelike(2):生成关卡中,我们已经生成了随机关卡,接下来就是让大胡子可以在关卡里自由自在走动。这一节我们主要完成的内容是:
- 基本移动逻辑
- 角色获取输入进行移动
本节你将学会什么?
- 认识父类(基类)/子类(派生类)/抽象类/抽象方法/虚方法
- 认识泛型函数
- 如何通过协程进行平滑移动
- 如何利用线性投射Linecast()检测碰撞
- 如何获取输入并且进行移动
一、实现基本移动逻辑——编写父类MovingObject
最终的游戏效果我们可以看到,大胡子和怪物虽然种族样貌均不同,但是它们在移动这块存在很多共同点:
- 都会移动
- 每次移动都是一样的距离
- 碰到障碍(墙、对方)都过不去,需要绕开
当然,也存在不同点:
- 角色遇到障碍墙可以打碎开辟道路,怪物不行
- 角色遇到食物和饮料可以捡起来吃吃喝喝加生命,怪物不行
- 怪物会追踪攻击角色并扣除角色一定量的生命,角色不能攻击怪物
根据上述可以得出结论,角色和怪物是使用同样的移动逻辑,差别只是在于遇到其他碰撞体的时候反应不同。那么,创建一个父类MovingObject编写移动逻辑,然后让角色和怪物的脚本都继承它,这样就可以避免同样的代码写两遍!不同的地方在子类里实现就可以了~
٩(๑❛ᴗ❛๑)۶ 子类可以继承父类的成员并且加以扩展,实现代码复用,节省代码时间,并且方便修改。
关于移动逻辑,画了个草草的非常简单的流程图如下:
MovingObject只管怎么移动,不关心移动的请求来自于哪里,所以第一步“获得移动的请求”是子类各自实现的,比如角色是通过键盘方向键输入,而怪物就要看角色是不是已经移动完毕,毕竟这是个回合游戏嘛!
右键Scripts文件夹,选择Create->C# Script,创建一个新的脚本,命名为MovingObject,双击打开进行编辑。
(多代码预警!!!(;´д`)ゞ)
第1步:自顶向下——AttempMove()
我们创建了一个方法AttempMove(),代码如图:
AttempMove()要实现的其实就是整个移动逻辑:接收方向信息,确定目的地并且判断该点是否存在障碍物(Move方法),否就平滑移动(SmoothMovement方法),是则根据障碍物类型来执行对应操作(OnCantMove方法)。比如移动主体是Player的话,判断如果是Wall则攻击使之破碎消失。
代码简析:
- 定义RaycastHit2D类型的变量hit,它将会作为参数传入Move()并且返回,用于存储线性投射检测到的结构体信息(即障碍物)。
- 调用Move()进行线性投射检测和移动,并把返回的布尔值赋值给canMove(可以移动返回true,不能移动返回false)。因为参数hit使用了修饰符out,所以也会返回hit变量的值。
一个函数只有一个输出值,如果想返回多个值的话就需要加out参数修饰符。
- 如果hit变量的transform为null,意味着前方并无障碍,就return退出方法,不再执行下面的代码。
- 在hit.transform不为null的情况下,获取hit变量的T组件并且赋值给hitComponent,如果hitComponent不为空则调用OnCantMove()进行相应的处理。
举个栗子:移动主体是Player,在前方有障碍的情况下,获取障碍的Wall组件,如果的确是有这个组件证明那就是Wall对象(障碍墙),那么就可以调用OnCantMove()去执行敲墙操作了!其他情况则维持原样,被挡住原地不动。
根据上述解析,我们知道应该要传入一个T参数,代表障碍物身上的某一个指定的组件的参数。这个组件类型不固定,可能是Wall,也可能是Player(假设移动主体是Enemy)。一般函数的参数都是指定了类型的,所以这时候应该怎么样传T才能让子类都适用?在这里我们推荐使用泛型方法。
泛型,其实就是通过把参数类型化来实现同一份代码操作多种数据类型。也就是说,当我们不确定传入的参数是什么类型,并且不同的类型下我们的代码逻辑是一样的时候,就可以使用泛型方法,实现更为灵活的复用。
使用泛型格式如代码所示,方法名后<T>,{之前用where T : 来指定T是属于什么,比如在这里是属于组件Component。关于泛型,感兴趣的可以网上搜索了解更多。
- 要注意,因为子类继承之后要进行重写修改,所以在AttempMove()前加了个修饰符virtual使之变成虚方法。
此外,在AttempMove()我们又看到了代码界一个很重要的好习惯。
为了代码的可读性和美观,单个函数内的代码不要太多行,过多行的情况下建议拆解成其他方法。
第2步:线性投射检测和移动——Move()
对目的地进行障碍物检测和移动的逻辑我们放在了Move()里。
- 新增公共成员变量blockingLayer,是进行线性投射的时候指定的LayerMask层。在Unity编辑器的Inspector窗口下,Tag右侧的Layer选项里就是不同的LayerMask。
- 新增私有成员变量boxCollider2D,指脚本所挂载的游戏对象上的碰撞器组件。
- Start()方法,对boxCollider2D进行初始化赋值。由于子类继承的时候会对Start()进行重写,因此在方法前增加virtual关键词。
- Move()方法,确定起点和终点,先关闭自身的碰撞器,然后调用Linecast()进行线性投射检测并且把返回值赋给hit,再把自身碰撞器开起来。如果hit.transform为null,则调用平滑移动函数SmoothMovement()进行移动并且返回true,否则返回false。
SmoothMovement()是协同程序,开启协程需要使用StartCoroutine函数。
- Linecast()方法,线性投射,是Unity自带方法。它会从开始位置到结束位置做一个光线投射,如果与指定的Layer mask层的碰撞体交互,就会返回真和一个RaycastHit2D结构体信息。这就是为什么之前在制作预制件的时候要把Player、Enemy、Wall、OuterWall这四个的Layer都设置为同样的BlockingLayer层了,因为遇到他们是不可移动的,那么就需要Linecast()来检测前方是否存在处于BlockingLayer层的碰撞体。
因为光线从中心点发射出去的时候会碰到自身的碰撞器,所以需要把自身的碰撞器先关掉,检测完了再开启。
第3步:协同程序平滑移动——SmoothMovement()
物体的移动一般是平滑的过程,不是瞬移。而在Unity里,实现平滑移动比较好的方式就是使用协同程序。
协程是分步骤执行代码的程序,遇到条件(yield return语句)会挂起暂停退出,直到条件满足才会被唤醒继续执行后面的代码。
使用协同程序的方法:声明一个返回值为IEnumrator的方法,然后在方法中使用yield return语法返回,在需要用协程的地方(比如上面Move方法末尾)通过StartCorutine方法去调用。
简单说明下:
- 新增公共成员变量moveTime,每次移动耗时,单位是s。
- 新增私有成员变量inverseMoveTime,在Start()赋值为moveTime的倒数。官方说法是乘法比除法更有效率(不懂这个说法),我倒是觉得这个变量应该指的是速度。因为每次移动的距离是1,那么根据速度=距离/时间,inverseMoveTime是速度没跑了。
- 新增一个私有成员变量rb2D,刚体组件,并在Start()进行初始化赋值。
- SmoothMovement()方法,使用sqrMagnitude来返回起点和终点的距离的平方并且赋值给sqrRemainDistance。
由于后面要拿来和最小浮点值float.Epsilon进行比较,在程序里面模长平分的计算成本比数量级要低。
- 在while循环里,当sqrRemainDistance的值大于float.Epsilon,也就是说距离大于0,就会进入循环进行移动。先调用MoveTowards()计算出下一次移动的目标位置newPosition(在当前地点和终点的连线上),再调用MovePosition()来移动刚体到newPosition。由于移动之后位置变了,所以重新计算了当前地点和终点的距离平方,并进入下一次循环。
- yield return null,表示剩余代码将在下一帧继续执行。也就是说代码每次进入while循环读到yield return null之后会暂停执行,下一帧再回来进行下一次循环。
暂停执行的时候程序会把移动的结果展示到屏幕,所以我们就可以看到物体平滑的移动,而不是while循环直接跑完了,我们只看到最终的结果,就是瞬移到终点。
第4步:抽象方法——OnCantMove()
这个方法在父类里特别简单。真的特别简单。
因为不需要具体实现!hhhh妈呀前面好多代码啊,看到这个方法好感动o(╥﹏╥)o
- 在OnCantMove()方法前面添加关键词abstract之后,它就变成了一个抽象方法,不需要具体实现。因为这个方法要实现的代码逻辑是,当不能移动并且障碍物是可互动的对象的时候要进行的操作。而每个子类都是不一样的处理方式,因此我们把具体的实现内容交给子类去添加。
- 因为传入的参数类型不固定,因此OnCantMove()也是使用泛型参数方式。
emmm,MovingObject类基本编写完毕。为什么说基本呢?切回到Unity编辑器,控制台非常友好地报了一个错误。
这是因为有抽象方法的类是抽象类,需要在类名前面用abstract关键词进行修饰。
二、创建可被破坏的墙——Wall Script
要想角色遇到Wall的时候能够击打敲碎开辟路线,需要Wall本身挂有一个脚本组件以便认定从而调用OnCantMove()。那么我们就来编写一个Wall脚本吧!(注意这里Wall是中间随机生成的障碍墙,并非周围那一圈OuterWall)
第1步:编写Wall Script
右键Scripts文件夹,选择Create->C# Script,创建一个新的脚本,命名为Wall,双击打开进行编辑。
- 新增两个公共成员变量,dmgSprite是被攻击一次之后的Wall图片,hp是Wall的生命/血量。
访问限制为public的类成员,可在Unity编辑器的Inspector窗口设置和更改属性值。
- 新增私有成员变量spriteRenderer,在函数Awake()里进行初始化赋值,是游戏对象Wall上挂载的Sprite Renderer组件。
- Start()改成Awake(),因为Awake()是在游戏对象生成之后立刻调用,不管是否enabled,而且Awake()调用在Start()之前。因此为了安全,官方也是推荐把初始化操作放在Awake()里。
- DamageWall(),执行Wall被破坏之后的处理。把自身的图片换成dmgSprite(表示攻击有效),hp扣除loss,如果hp小于等于0则隐藏Wall(并且Wall上的碰撞器等组件都关闭),在玩家看来就是墙被打碎了,并且可以移动过去了。
第2步:挂载设置Wall Script
脚本写好之后要挂载在游戏对象上才能生效。回到Unity编辑器,点击Assets内的Prefabs文件夹,同时选择Wall1-Wall8,点击最上方菜单栏的Component-Scripts-Wall,把Wall脚本都添加到Wall预制件。
可以看到现在每个Wall预制件右侧的Inspector窗口都多了个Wall脚本组件。
在上面可以自由设定Wall的生命值hp。现在我们需要点击Dmg Sprite选项右侧的小圆圈,打开Sprite选择页面,为每个Wall预制件选择一个被攻击时候的Sprite!
按顺序选择就好,不过官方只给了7张图,所以咱们Wall5和Wall6都选择了编号52的那张图。
三、让角色先走起来——Player Script
完成MovingObject类只是第一步,还需要Player和Enemy分别继承它并且扩展才能真正的让角色和怪物移动起来。我们首先想要实现的是角色的移动,因此先创建一个Script,命名为Player,双击打开编辑。
第1步:获取输入进行基本移动
在Player脚本里,我们第一时间要做的是获取外部的移动请求,然后才能调用AttempMove方法去进行移动。
- Player类必须继承MovingObject类,所以冒号后面记得修改为MovingObject。
- 因为要实时不停的接收移动请求输入,因此我们把相关代码放在了Update()里。Update()是在每次渲染新的一帧的时候会调用。
- 在Update()里,定义了int类型变量horizontal、vertical,代表移动方向向量。先调用GetAxisRaw()获取原始轴坐标值并且分别赋值给horizontal、vertical,然后设定当horizontal不为0的时候,vertical强制性为0,也就是说不能斜着走,只能上下左右移动。最后判断,当horizontal和vertical任何一个值不为0,就调用AttempMove<Wall>()进行移动。
- 为了能看脚本不报错从而让角色移动起来,我们把OnCantMove()这个抽象方法也先写上,代码空着以后补上。
经历上述一大堆的代码和操作,我们终于可以尝试着去让大胡子移动起来了。切回到Unity编辑器,把Prefabs文件夹的Player预制件拖到Hierarchy窗口生成对象实例,然后把Player Script添加到Player对象上。
Blocking Layer选择BlockingLayer,然后运行游戏,再按下键盘的方向键操纵大胡子移动,看看我们辛苦的成果吧!
啊咧,为何和我们想象中的不大一样?这就是传说中的买家秀吗?!!!∑(゚Д゚ノ)ノ
大家会发现大胡子的确可以动起来了,碰到Wall、Enemy、OuterWall也会被挡住,但是存在好几个问题。
- 并不是按一下就走一格,而是比一格还远,而且每次还不一样的距离。
- 碰到食物、Wall、Exit、Enemy都没有相应的效果。(还未实现)
- 在大胡子走动一次之后,理应是Enemy的回合,但是它们傻傻站在原地不动。(还未实现)
第2和3是因为我们还没编写相关的逻辑代码。而第1点,或许已经有聪明的同学想到是什么原因了。提示一下,和Update()这个方法的特点有关系!仔细想想~~~
———建—议—思—考—下—再—看—答—案———
前面提到,Update()是在每次渲染新的一帧的时候会调用!在我们的金贵的小手指按下方向键到起来的这短短不到1S的时间内,游戏已经渲染了好几帧,也就是调用了好几次Update(),获取了好几次的移动请求输入!因此虽然我们只按了一次方向键,游戏里的大胡子却移动了好几次,跑的老远。那么,我们要怎么做才可以达到我们想要的效果,就是按一次方向键执行一次Update()走动一格呢?
对这个回合游戏来说,角色的移动是和怪物的移动息息相关的。角色移动两次之后就转变成是怪物回合,每一只怪物都移动完毕了又会转回角色回合,然后一直进行这个循环。
也就是说,现在没有怪物移动逻辑代码,因此没办法切换到怪物回合,而我们暂时也不打算现在就转去编写Enemy的移动代码,所以接下来我们将用一个取巧的办法来解决这个问题,后续做了Enemy的移动之后会把这些再修正。
第2步:临时修正同时获取多次输入
现在的问题是在角色还没移动完毕到位的时候,程序又通过Update()获取了新的输入请求,导致角色在半路又决定走多一格。那我们是否可以人为设置一个开关,在角色开始移动的时候把开关关掉,这期间不能获得新的输入,角色移动完毕再把开关开起来,这时候才能获得新的移动输入请求?让我们试试这个办法。
首先,在GameController脚本里添加起开关作用的变量playerTurn,布尔值,初始值为true。因为要在其他脚本调用所以访问限制为public,但是不希望在Unity编辑器可以进行改动,所以用[HideInInspector]隐藏公有变量。
然后我们在Player脚本的Update()里添加如下代码:
if语句是判断当playerTurn为false的时候return返回,不执行后续代码获取输入。然后横线处是确定了有实际移动输入请求的时候把playerTurn改成false,这样就不会在移动期间又进入Update()里面获取输入。
移动期间把开关关了,那么移动完毕了要把开关开起来,不然就没法进行下次移动。所以我们在MovingObject脚本的SmoothMovement()和AttempMove()都添加了以下代码:
为什么同一句代码需要在两个地方都添加?这是因为每次移动的时候有两种情况,可移动和不可移动。无论是哪种情况,都需要把playerTurn重新改回true,以便获取下一次的移动请求。
好了,这时候我们保存脚本,回到Unity编辑器运行游戏。
成功!可以看到,移动一次的距离是刚好一个格子了。
然后我们还有好多没实现,如捡东西吃、开路、被敌人砍、进入下一关等等。我写这些很慢(担心讲不清所以老修改),就让我们在下一篇再见吧!