Unity官方教程 2D Roguelike(3):移动逻辑

2D Roguelike 最终效果

前言

Unity官方教程 2D Roguelike(2):生成关卡中,我们已经生成了随机关卡,接下来就是让大胡子可以在关卡里自由自在走动。这一节我们主要完成的内容是:

  • 基本移动逻辑
  • 角色获取输入进行移动

本节你将学会什么?

  • 认识父类(基类)/子类(派生类)/抽象类/抽象方法/虚方法
  • 认识泛型函数
  • 如何通过协程进行平滑移动
  • 如何利用线性投射Linecast()检测碰撞
  • 如何获取输入并且进行移动

一、实现基本移动逻辑——编写父类MovingObject

最终的游戏效果我们可以看到,大胡子和怪物虽然种族样貌均不同,但是它们在移动这块存在很多共同点:

  • 都会移动
  • 每次移动都是一样的距离
  • 碰到障碍(墙、对方)都过不去,需要绕开

当然,也存在不同点:

  • 角色遇到障碍墙可以打碎开辟道路,怪物不行
  • 角色遇到食物和饮料可以捡起来吃吃喝喝加生命,怪物不行
  • 怪物会追踪攻击角色并扣除角色一定量的生命,角色不能攻击怪物

根据上述可以得出结论,角色和怪物是使用同样的移动逻辑,差别只是在于遇到其他碰撞体的时候反应不同。那么,创建一个父类MovingObject编写移动逻辑,然后让角色和怪物的脚本都继承它,这样就可以避免同样的代码写两遍!不同的地方在子类里实现就可以了~

٩(๑❛ᴗ❛๑)۶ 子类可以继承父类的成员并且加以扩展,实现代码复用,节省代码时间,并且方便修改。

关于移动逻辑,画了个草草的非常简单的流程图如下:


移动逻辑

MovingObject只管怎么移动,不关心移动的请求来自于哪里,所以第一步“获得移动的请求”是子类各自实现的,比如角色是通过键盘方向键输入,而怪物就要看角色是不是已经移动完毕,毕竟这是个回合游戏嘛!

右键Scripts文件夹,选择Create->C# Script,创建一个新的脚本,命名为MovingObject,双击打开进行编辑。
(多代码预警!!!(;´д`)ゞ)

第1步:自顶向下——AttempMove()

我们创建了一个方法AttempMove(),代码如图:

AttempMove()

AttempMove()要实现的其实就是整个移动逻辑:接收方向信息,确定目的地并且判断该点是否存在障碍物(Move方法),否就平滑移动(SmoothMovement方法),是则根据障碍物类型来执行对应操作(OnCantMove方法)。比如移动主体是Player的话,判断如果是Wall则攻击使之破碎消失。

代码简析:

  • 定义RaycastHit2D类型的变量hit,它将会作为参数传入Move()并且返回,用于存储线性投射检测到的结构体信息(即障碍物)。
  • 调用Move()进行线性投射检测和移动,并把返回的布尔值赋值给canMove(可以移动返回true,不能移动返回false)。因为参数hit使用了修饰符out,所以也会返回hit变量的值。

一个函数只有一个输出值,如果想返回多个值的话就需要加out参数修饰符。

  • 如果hit变量的transformnull,意味着前方并无障碍,就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()里。

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方法去调用。

SmoothMovement()

简单说明下:

  • 新增公共成员变量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()

这个方法在父类里特别简单。真的特别简单。

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,双击打开进行编辑。

Wall Script
  • 新增两个公共成员变量,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组件

在上面可以自由设定Wall的生命值hp。现在我们需要点击Dmg Sprite选项右侧的小圆圈,打开Sprite选择页面,为每个Wall预制件选择一个被攻击时候的Sprite!

Wall1的DmgSprite

按顺序选择就好,不过官方只给了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对象上。

Player脚本组件

Blocking Layer选择BlockingLayer,然后运行游戏,再按下键盘的方向键操纵大胡子移动,看看我们辛苦的成果吧!

最初的旅行

啊咧,为何和我们想象中的不大一样?这就是传说中的买家秀吗?!!!∑(゚Д゚ノ)ノ
大家会发现大胡子的确可以动起来了,碰到Wall、Enemy、OuterWall也会被挡住,但是存在好几个问题。

  1. 并不是按一下就走一格,而是比一格还远,而且每次还不一样的距离。
  2. 碰到食物、Wall、Exit、Enemy都没有相应的效果。(还未实现)
  3. 在大胡子走动一次之后,理应是Enemy的回合,但是它们傻傻站在原地不动。(还未实现)

第2和3是因为我们还没编写相关的逻辑代码。而第1点,或许已经有聪明的同学想到是什么原因了。提示一下,和Update()这个方法的特点有关系!仔细想想~~~

思考中

———建—议—思—考—下—再—看—答—案———

前面提到,Update()是在每次渲染新的一帧的时候会调用!在我们的金贵的小手指按下方向键到起来的这短短不到1S的时间内,游戏已经渲染了好几帧,也就是调用了好几次Update(),获取了好几次的移动请求输入!因此虽然我们只按了一次方向键,游戏里的大胡子却移动了好几次,跑的老远。那么,我们要怎么做才可以达到我们想要的效果,就是按一次方向键执行一次Update()走动一格呢?
对这个回合游戏来说,角色的移动是和怪物的移动息息相关的。角色移动两次之后就转变成是怪物回合,每一只怪物都移动完毕了又会转回角色回合,然后一直进行这个循环。
也就是说,现在没有怪物移动逻辑代码,因此没办法切换到怪物回合,而我们暂时也不打算现在就转去编写Enemy的移动代码,所以接下来我们将用一个取巧的办法来解决这个问题,后续做了Enemy的移动之后会把这些再修正。

第2步:临时修正同时获取多次输入

现在的问题是在角色还没移动完毕到位的时候,程序又通过Update()获取了新的输入请求,导致角色在半路又决定走多一格。那我们是否可以人为设置一个开关,在角色开始移动的时候把开关关掉,这期间不能获得新的输入,角色移动完毕再把开关开起来,这时候才能获得新的移动输入请求?让我们试试这个办法。
首先,在GameController脚本里添加起开关作用的变量playerTurn,布尔值,初始值为true。因为要在其他脚本调用所以访问限制为public,但是不希望在Unity编辑器可以进行改动,所以用[HideInInspector]隐藏公有变量。

playerTurn

然后我们在Player脚本的Update()里添加如下代码:

增加获取输入的条件

if语句是判断当playerTurn为false的时候return返回,不执行后续代码获取输入。然后横线处是确定了有实际移动输入请求的时候把playerTurn改成false,这样就不会在移动期间又进入Update()里面获取输入。
移动期间把开关关了,那么移动完毕了要把开关开起来,不然就没法进行下次移动。所以我们在MovingObject脚本的SmoothMovement()AttempMove()都添加了以下代码:

修改MovingObject

为什么同一句代码需要在两个地方都添加?这是因为每次移动的时候有两种情况,可移动和不可移动。无论是哪种情况,都需要把playerTurn重新改回true,以便获取下一次的移动请求。
好了,这时候我们保存脚本,回到Unity编辑器运行游戏。

正常移动

成功!可以看到,移动一次的距离是刚好一个格子了。
然后我们还有好多没实现,如捡东西吃、开路、被敌人砍、进入下一关等等。我写这些很慢(担心讲不清所以老修改),就让我们在下一篇再见吧!

上一章传送门:生成关卡
下一章传送门:角色移动

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容