UnityDemo-TheBrave-制作笔记

这是我跟着b站up主MStudio的视频学习制作的,大体上没有去做一些更新的东西,这里只是一个总的总结。在文章的最后,我会放上可以游玩该游戏的链接和exe可执行文件,不过没有对游戏内容进行什么加工,只有基本的功能实现罢了。

最开始的Unity引擎的下载。导入文件什么的就不赘述了,相信对于大家都是上手就能学会的基本操作而已;至于tilemap的使用,虽然也不能非常简单,但毕竟不太涉及代码,上手也简单,所以也就不多说,我们从第一个大模块:Player的创建开始说起。

Player

New Input System

unity的新版本加入了新的输入系统,可以非常方便地帮助我们读取设备的输入并使用。

首先我们需要在windows->package manager处安装:

搜索InputSystem即可。

然后去edit->project settings->player->other settings处:

找到Active Input Handing处,选择Input System Package (New)即可。

这样我们就可以创建Input Actions了:

在Assets处右键点击create,翻到最下方可以看见Input Actions。

打开就是这样的,我们可以添加action maps,这其中包含了actions,我们的actions中就具体地包括了一系列读取键盘的输入,比如:

如图所示,我们定义的名为Gameplay的Action Maps中,定义了名为Movement的动作,这其中包含读取W,A,S,D四个键盘的指令。

当然,Unity已经帮我们准备好了更多更方便的用法。

比如在player身上直接点击add component,就可以看到Player Input,

这是Unity自带的Input Actions,可以看到已经帮我们设定好了基本的移动,跳跃等按键。

在这里的Behavior中选择Invoke Unity Events后,我们就可以选择下列的一系列事件了,比如我们希望在键盘读取到Move的输入后希望触发一系列方法等。

当假如我们还是希望用代码的方式来操控玩家的话,我们还可以:

打开我们的Player Input帮我们生成的Input Action Assets,这里有一个生成c#的脚本,点击后就可以得到:

倒不是不想把代码直接给大火看,只是这个代码的长度超乎我的想象,我就截个图意思一下就行。关键也不是他的内容,而是我们现在就可以用代码的形式来调用这个脚本从而达到用代码操控玩家的目的了。

创建脚本,名为PlayerController,在一开始定义一个变量,然后在awake函数处实例化PlayerInput组件就可以使用了。

注意要using UnityEngine.InputSystem才可以。

这里可能有人有疑问为什么其他变量的初始化都是GetComponent而inputControl是实例化(new),因为我们的PlayerInputControl类并不像其他类一样是直接挂载在同一个对象上的(Player),他只是被放在InputSystem中的一个Unity自带的类,不实例化的话我们没有可以调用的实体。

分别找个合适的时间点开启和关闭我们的inputControl即可。

那现在我们可以通过inputControl来读取外部设备的输入了,具体来说如何操作呢?

在Update中,我们读取方向,注意这些层层递进的函数的名字,其实就是我们的Input Actions的界面的层级,最后使用一个ReadValue函数读取数值。如何知道ReadValue函数呢?其实最简单的方法就是你输入代码到上一层,IDE自然会弹出Move可以调用的方法,变量等;或者去找代码手册,这也是不错的方法。

这样我们就完成了对New InputSystem的操作,并且可以通过这个系统读取外部设备的输入了。

Movement and Turn Back

既然都可以读取设备输入了,该具体地给人物注入灵魂了。

首先,人物移动的基础是我们需要有完整的物理系统,更具体地说:我们需要有碰撞体和刚体。

碰撞体和刚体分别代表了什么呢?碰撞体是Unity进行碰撞检测的基础,如果没有碰撞体,就没有办法进行碰撞检测,而如果没有刚体,就没有办法对物体施加力的效果(比如重力)。

在PlayerController的脚本中,同样的,我们也需要一开始定义碰撞体和刚体的变量并获取他们。

这里的coll的获取碰撞体的方法直接从游戏对象身上获取也是可以的,这里选择从rb(刚体)身上获取的原因在于这样可以多一层对刚体的检测。

万事俱备,让我们的人物移动起来吧

这个Move函数的逻辑非常的简单:我们首先已经得到了方向(见上小节),我们定义一个速度变量speed后,简单地设置刚体的velocity变量即可(速度乘以方向乘以时间),这样玩家就可以根据按键向左或向右移动了。

但是这样的玩家的图像是完全不动的,我们还需要添加翻转来解决这个问题。翻转的方法有很多,但最简单的一个就是修改玩家的localScale的参数。

简单地说,我们判断现在键盘输入的方向是向左还是向右,然后根据左右调整原图片的localScale即可。

最后我们放在Update函数中调用即可。

有人可能疑惑为什么放在FixedUpdate中而不是Update中,而这就不得不提这两种Update的区别了。FixedUpdate是物理帧更新,也就是每个固定的时间更新一次,一般来说默认是0.02s,而Update是逻辑帧更新,也就是电脑帧数,那么我们知道电脑帧数有可能波动,也容易受性能影响。作为最基础的移动函数,我们还是希望能更稳定。

这样我们就实现了简单的移动和翻转了。

Jump

跳跃本质上其实和移动没有太大差距,都是向玩家身上的刚体添加力,但不同的是玩家的移动我们可以直接更改速度,但是玩家的跳跃我们必须是添加力的方式,而且必须是添加冲击力而不是持久力(不然你就在天上不下来了),所以我们不能简单的去修改人物y轴的速度。

非常简单的一个AddForce函数,定义一个跳跃的力并在函数中写好力的类型即可。

PhysicsCheck and Gizmos

现在我们需要添加一个通用类:物理检测,我们可以设想一下在哪些地方我们需要进行物理检测,比如地面检测,墙体检测。而为什么说是通用类,是因为除了我们的玩家,包括我们的敌人,npc,建筑等,我们可能都需要进行物理检测,所以比起直接在玩家上添加功能,我们直接写一个物理检测的类。

物理检测的前提,当然是需要有碰撞体,不然没有Unity的碰撞检测,何来物理检测之说呢?我们可以假设有两个需要进行物理检测的碰撞体,然后我们在这个游戏中需要进行的物理检测包括哪些方面呢?第一肯定是地面检测:我们所有的物体都需要进行检测是否在地面上,这样可以和跳跃的状态形成对比;第二就是墙体碰撞检测,比如当敌人巡逻碰到墙体的时候,他需要进行物理检测判断自己走到路的尽头了并回头继续巡逻;第三当然就是我们的玩家与敌人发生的碰撞了,不过这部分内容我们暂时不急着实现,因为这部分内容更严格意义上说属于攻击/伤害类的内容了。

定义了三个bool类变量,分别为是否在地面上、是否触碰左墙体、是否触碰右墙体,然后定义了检测范围的一些参数:底部偏移、左偏移、右偏移,为什么需要偏移量呢?因为我们进行物理检测时只希望物体的边缘进行检测,这样会更精准,更合理,也可以减小一部分计算上的开销。

这里可以看到额外添加了一个manual的bool变量,这个变量是干嘛的呢?我们可以人工地去设置物理检测的范围,但是如果懒得做的话,我们就自动添加一个左右偏移量,这个量的参数设置比较有意思:coll.bounds.size.x代表什么呢?其实代表的是这个碰撞体的包围盒的大小中的x方向的大小,除以2就是包围盒x方向的中心,再加上设定的偏移值就得到了右偏移值,反之得到左偏移值。

至于核心的check()函数,反而非常的简单:因为unity早就帮我们做好了接口,我们就调用一个overlapcircle(注意大小写,我这里偷懒了)函数即可,这个函数在unity官方代码手册中都有讲解,我就不赘述了。而Gizmos函数则是一个专门用来绘制曲线,或者说可视化的一个函数,对于检测的东西有一个这样可视化的函数来的话会直观很多。

Animation

我们之前已经搞定了人物的基本逻辑上的代码了,他可以左右移动,跳跃,进行物理检测。但是我们还缺少一个核心部分:动画。

现在游戏中的动画技术已经有很多种了,比如最常见的帧动画,骨骼动画,以及如关键帧动画等一些借助计算机算力的动画方法,在我们这个小游戏里,我们就使用最简单的帧动画:何谓帧动画?相信很多对动画有所了解的人都知道,其实就是一帧画面一帧画面的快速播放形成的动画罢了。在Unity中,有自带的帧动画功能。

但是在制作动画前,我们先需要一个管理动画播放的东西,这个东西就是Animator。

我们都知道对于程序员来说,关心的从来不是如何制作动画,而是在何时播放动画,对于客户端来说尤其,说白了,客户端做的就是一个事,交互嘛。那我们如何知道何时播放动画呢?当然是我们需要得到人物的状态并根据人物的状态来播放动画,人物受伤了就做受伤的动画,人物跳跃就做人物跳跃的动画,人物走到了某个特定的坐标就开始播放特定的动画...而管理这一切的东西就是状态机。Unity是有自己的状态机的,名为Animator Controller,在这里,我们就可以精准地掌握动画播放的时机。

打开我们的player的Inspector界面点击add component,就可以找到我们的animator了,在这里我们可以看到几个选项:Controller就是我们刚说的状态机,我们可以在assets界面右键后create里找到,avator与apply root motion则都是跟骨骼动画相关的部分,不多细说。下面还有一个cilp count,这个是什么意思呢?其实这里的cilp就是我们的动画片段(animation cilp),也就是说这里的animator里有十四个片段。

回想我们之前的内容,在这里我们需要的是:制作人物移动和跳跃的动画,其实还包括一个:人物不移动的动画,也就是idle(闲置)状态的动画(你玩家不移动的时候也不能一动不动吧)。

我们先从Idle部分开始做吧,首先,打开animation界面(windows->animation->animation)

创新新片段,找好位置保存(当然要放在assets根目录下)。

然后就把提前找好的动画素材放上来,调整好采样率即可。

这里我们遗漏了两个东西:一个是可以插入关键帧事件:什么叫关键帧事件呢?我们知道可以通过订阅事件的方式来调用方法,那么同样的,我们可以把动画播放到某一帧看作一个事件,然后订阅这个事件就可以实现同样的效果。

第二个则是我们的add property,这个功能是干嘛的呢?其实就是在你动画的基础上进行一些修改,比如:

什么意思呢?在播放动画的时候,我们可以对挂载动画的对象(sprite)的如transform、Sprite Renderer、刚体等进行一系列的修改。

anyway,把动画片段做好了,我们就可以把他放在animator controller上去了。

这个是已经做好了的animator controller,我们可以看到默认的状态就是idle(idol什么鬼),而他可以根据一些条件转换为播放奔跑的条件,什么条件呢?

首先我们可以看到最左边的一栏:Parameters,参数。这个东西就是我们进行动画间转换的基本:再看我们右边一栏的的Conditions:里面涉及到的动画转换的条件就是我们的参数,同时上方的settings则是一些动画转换的细节,比如是否有退出时间啊,有渐入渐出效果诸如此类。

既然知道了通过参数来控制动画转换,可问题是我们如何将参数与实际的游戏联系起来呢?那答案当然是代码了~

创建新脚本,命名为PlayerAnimation,专门用来控制人物的动画部分。

和之前的代码如出一辙地获取组件,然后最核心的方法在于:Animator类的anim变量中的Set方法,这里的set方法就是将我们刚才Animator中的参数与游戏中实际的参数联系起来的方法。这样我们的parameters就和实际的参数如刚体的速度,物理检测返回的是否在地面上等联系起来了。

制作动画的基本思路就是这样,在这里我们再展开细说一下更复杂的部分,比如玩家跳跃的动画。

并不是所有动画都像idle状态一样非常的简单明了,很多动画往往是多阶段的,比如跳跃动画,我们可以想想,如果要做出一个跳跃动画,具体需要哪些参数的判断,动画会有哪几个阶段。

首先,跳跃有个最基础的要求:你得是在地面上才可以跳跃,总不能左脚踩右脚螺旋升天吧(CF又领先版本十年了吗),接着跳跃总的来说是有三个阶段的:起跳到上升,上升到停滞,停滞到落地。起跳到上升的过程中,回顾我们之前做的Jump功能代码块,我们通过给刚体一个y轴方向的冲击力实现跳跃的过程,那么这个阶段内,刚体在y轴上的速度会出现一个从大到小的过程,那我们就读取刚体的y轴速度,在y轴速度归零的前一段时间里,我们认定是第一阶段(为什么是前一段时间,因为Unity实际计算的速度很快,速度为0的时刻非常迅速地就过去了),然后从速度归零到速度开始变为负值,认为是第二阶段,直到落地,再次检测到isGround,就认为第三阶段结束,Jump动画结束。

既然都说了有三个阶段,那显然我们需要三个动画片段,并且希望将他们根据刚体的y轴速度变化来播放,所以我们需要一个:

Blend Tree:混合树,我们可以利用这个工具实现多个动画片段的融合。

Character

动画也制作完成的话,我们就可以开始制作一些数值上的东西了,这里的标题是角色的意思,但其实我更愿意理解为:属性。和物理检测一样,显然属性也是一个通用类,主要应用的场景也显然是玩家和敌人。

谈论到属性,我们能想到什么?生命值和攻击力,这是两个不可能不存在的属性,在这基础上,我们还可以不断地添加新东西。但既然我们说到了攻击力,我们是不是就需要进行伤害的判定与计算了呢?

如果从敌人的角度来说,伤害的判定可以做得简单一些:我们就认为玩家与敌人产生碰撞的话就对玩家造成伤害即可,可是玩家显然不能只是硬碰硬,他可以用更长的武器比如剑来对敌人造成伤害,而这又是一个很复杂的过程了。

扯远了,我们就先做好基本的造成伤害的函数即可,其实内容也非常简单:我们假设有一个攻击者与被攻击者,显然我们需要的是他们的属性类,将被攻击者的生命值减去攻击者的攻击力即可。然后我们还需要一个无敌时间:就像我之前所说的,unity计算的速度非常的快,如果不设置无敌时间的话,在伤害造成的瞬间,unity可能就计算了成千上百次伤害(三种update的方式计算的速度都非常快),这样被攻击者一瞬间就被打成筛子了,所以我们需要一个新的判断是否为无敌的bool类型以及一个无敌时间的倒计时计时器。

首先是名为Character的脚本。

这里可以注意到,我们将造成伤害定义成了unityevent,且接收参数为Transform,这是为什么呢?首先是造成伤害(OnTakeDamage)本身会牵扯到很多状态的变化:我们需要切换动画,需要计算生命值变化,需要计算无敌时间,而且在这个demo里还希望产生当受伤时出现后退的效果,比起做成bool类型一个个进行判断,我们直接做成事件来更直观且更容易管理。

可以看到做成事件之后,我们可以在inspector窗口非常直观地看到每个事件被订阅的方法,这样非常方便我们来进行操作。

然后我们还需要添加一个判断角色是否死亡的函数,毕竟人都死了还搞这么多干嘛。死亡显然也是一个牵扯很多函数的事件,但在这里我们先不急着实现,而是先把概念和代码搞出来。

这里就是我们的造成伤害的函数以及出发无敌的函数了,逻辑上来说,我们先判断是不是无敌,是不是死了,不是的话我们就扣除血量并出发无敌,然后触发OnTakeDamage函数,传入的参数是attacker的transform。然后注意在所有的判断外,我们一定触发OnHealthChange事件,传入的Character参数就是this(挂载这个脚本的对象的这个脚本),触发无敌函数的代码也很简单,开始倒计时。

我们将倒计时的函数放在Update里。

如果有仔细看代码的小伙伴就会发现,我们还遗漏了一个类,那就是Attack类。

Attack类的内容非常简单:我们只需要定义好伤害值,然后调用OnTriggerStay2D的方法在里面加入检测到碰撞后执行TakeDamage方法即可。(OnTriggerStay2D是一个Unity自带的函数,用于检测碰撞持续发生的期间)

可能大家被绕得有点晕,让我用简单的总结一下:关于人物的属性计算,我们分成了两个脚本,Character脚本中包含了人物的血量定义,无敌时间计数,死亡判断,以及三个事件:造成伤害,血量改变,死亡。这个脚本中主要就是定义如何触发各种事件,以及定义扣除血量等基本方法,这些东西都被封装到TakeDamage函数中;而Attack脚本中,我们调用了OnTriggerStay2D函数用来调用我们在Character中定义的TakeDamage方法,也就是说Character定义好所有内容,由Attack函数来使用。

Hurt & Dead

现在我们需要考虑的就是如何实现玩家与敌人的受伤死亡了,当然为什么这里我们都还没开始做敌人的部分就要考虑呢?因为包括死亡在内的这种通用类功能,我们不可能后续做到需要使用的对象时再根据对象来返回来改通用类(也不是不可能,尽量避免),所以在一开始做的时候就需要考虑周全。

敌人的死亡其实非常简单,首先它的血条肯定是清空的,然后需要播放死亡的动画,最后我们只需要调用物体销毁的函数毁掉敌人的GameObject即可。可是玩家的显然没有这么简单,死亡后除了基本的血条清空和播放动画以外,我们还希望在过一段时间后可以重新开始。

受伤则是大差不差,扣除血量,播放动画。这点玩家和敌人按理来说都一样才对。

我们一个一个来做,现在暂时只有玩家的部分:

扣除血量:

是的,其实在之前的部分我们已经顺便将受伤的内容做好了,我们只需要给敌人挂载好Attack脚本,然后发生碰撞后就会使用TakeDamage函数,然后TakeDamage函数中就会扣除角色血量,触发OnTakeDamage事件了。

然后我们回到动画的部分中:

首先我们当然需要更具提前准备好的素材来制作好玩家受伤的动画。这里可以注意到,我们比起之前的动画似乎在add property部分添加了些新东西:是的,我们在动画片段中对spritr renderer进行了改动,更准确地说,sprite renderer里的材质里的color值进行改动。

在Animator中,我们这里需要一些新鲜操作:

我们可以看到animator中左边一栏有一个Layers的选项,这个层级让一个animator中可以包含不同的状态机,且不同的layer之间甚至可以互相影响。我们选择将受伤的动画单独做一个层级,希望他与之前人物基本的移动动画进行区分。

这里可以注意到我们hurt layer层级中的entry后先是连接了一个new state而不是像base layer中连接idle状态,这是为什么呢?就像我说的,我们希望hurt层专门用来制作角色受伤的动画,那么我们角色idle的状态根本就不适合放在这个层级。但是我们仍然需要一个一开始的默认状态方便我们进行各种状态的转换(entry并不是一个状态,它无法进行多根连线),所以我们就放一个无动画的New state作为我们的新层级的默认状态。

我们定义一个trigger变量的parameter作为切换到hurt动画的condition,同样在PlayerAnimation脚本中设置它的值。

这里的注释也是解释了为什么要写成两个函数,因为SetAnimation函数是要放在update函数中进行实时检测的,而关于受伤和攻击的动画我们都是触发了才播放。

这里我们可以注意到,hurt动画的inspector界面里居然有一个脚本,这是怎么回事呢?

这个脚本是继承自StateMachineBehaviour,这个脚本是Unity自带的关于AnimationCilp的函数,它里面包括了五个跟动画状态切换相关的函数:Enter,Update,Exit,Move,IK(IK是逆向运动学的意思),在这里我们可以很方便地在每个状态地特定时段加入方法,比如这里我们在hurt状态离开时设定挂载animator的对象的PlayerController中的bool参数isHurt为false。

受伤的动画大体上就是这样了,现在我们再来做死亡的部分。大体上也差不多,制作好动画片段,在animator给定一个参数判断是否播放死亡动画,在playeranimation中给这个参数设置值。

这里必须要说的是我们的death状态后需要直接连接exit,这样当玩家死亡后,会进入动画状态机"死机"的状态,然后其他并无特殊之处。

还有就是死亡作为事件之一,我们也要想明白玩家死亡会带来的影响。

死亡在这方面其实反而比较简单,因为我们可以一刀切地进行处理,我们在PlayerController中直接关闭InputControl的使用。

这样我们就做好了玩家受伤和死亡的动画了。

Attack

玩家现在也会受伤了,也会"死"了,现在是时候让我们举起武器进行反击了。

我们的attack函数已经写好了,是基于碰撞检测的(OnTriggerStay2D),问题是我们要如何将这个函数具体地应用在玩家身上呢?

相信其实很大一部分人已经想到了:我们给玩家的实际攻击范围,比如说剑的身上套一层物理检测,把attack挂载在剑的身上就可以。但问题是,咱们这个demo里没有专门的剑的独立的sprite呀,那咋办呢?我们首先把玩家attack的动画做出来,然后在attack播放动画的每一帧里,我们都实时地添加碰撞体,这样就(?)可以实现了。

那么我们首先得先实现attack的动画,而这也并不简单,和跳跃一样,我们的Player的attack也会有多重状态,比如我们一直按攻击键就可以打出一套连招,不过不同的是跳跃我们本质上只是一个键触发,之后我们只需要根据一个参数的不同来调整不同的动画播放即可,但是攻击是我们要检测玩家是否连续按攻击键。

为了方便我们仍然将攻击的动画在animator里单独设定为一个Layer,并且将多个攻击动画连接起来,我们每一个攻击的动画都设定为默认播放一次,在动画的播放期间如果检测到又一次攻击键被按下的话我们就进入下一个攻击键,不然就回到默认状态。

其中每个攻击状态间的连接:

可以看到有两个参数,一个是trigger类型的attack,一个是bool类型的isAttack,我们之所以要使用两个参数就是希望一个参数能够确定进入攻击状态一个参数确定是否连续攻击。

我们在PlayerController中实现playerAttack函数,将bool变量isAttack改为true并播放攻击动画。

当然,我们再来复习一遍:PlayerAnimation中的PlayAttack函数,我们设置animator中的trigger类型的参数attack。

然后我们继承一个状态机函数来写一个攻击状态结束的函数,我们在进入状态的时候将isAttack改为true,离开时改为false。

我们来重新梳理一下,我们在PlayerController中实现PlayerAttack的方法,并订阅inputcontrol中的start事件:这个事件是检测键盘的输入的。PlayerAttack中包含了修改isAttack变量以及调用PlayerAnimation的PlayAttack函数,这个PlayAttack函数中包含的是触发animator中的trigger类型attack,这样我们就可以在状态机中进入第一个攻击状态,然后每一个攻击状态都挂载了继承自状态机自带的脚本的状态转移的脚本,每一个攻击状态结束就会将isAttack重新设置为false,然后这个时候我们又会回到PlayerAttack中的start事件,只要start事件继续触发就会继续执行。

这样我们就实现了人物攻击的动画播放逻辑了,现在我们需要做的是将人物的攻击添加碰撞体以便可以进行碰撞检测。

让我们首先打开其中一个attack的片段,并在特定的帧里添加武器所能涉及到的碰撞体,这样当动画播放到这一帧时就能出现碰撞检测来执行TakeDamage函数了。

那问题是我们只希望动画在这一帧的时候出现这个碰撞体,要怎么做到呢?其实也很简单,我们在Player下面添加gameobject并在这个gameobject里添加我们需要的碰撞体。然后我们通过之前提到的Add Property函数,在这里我们可以对player下的gameobject进行控制,比如控制isActive,然后我们默认关闭碰撞体,只有在动画播放在特定帧的时候才激活这个gameobject即可。

这样,我们就彻底实现了玩家的攻击的动画与判定的内容了。

总结

至此我们完成了玩家需求的基本功能:我们的玩家可以操控角色移动,跳跃,进行攻击以及受到攻击,会死亡,所有配套的动画都已经做好,现在让我们开始做敌人的需求。

Enemy

现在我们来到了敌人的制作,事实上敌人在很多部分与我们的玩家是类似的,比如我们之前说的通用类:物理检测,血量计算,伤害计算等在敌人身上也同样适用。我们只需要将这些已有的东西添加到敌人身上即可。

不过敌人的移动显然和玩家并不一样:我们玩家移动是读取键盘的输入,而敌人移动需要有自己的逻辑,一般来说,敌人会在一个特定的区域内进行巡逻,当他发现玩家时,则会对玩家展开追逐并达到一定距离后进行攻击。这里就涉及到了三种状态:巡逻,追逐与攻击。

我们首先写一个大的Enemy类,定义所有敌人需要的参数,然后具体到每一个敌人后去继承这个大类的基础上再进行修改即可。

图中的Transform变量是用来判断攻击者的位置来进行反击的:设想我们的敌人被玩家攻击了,那他无论是哪种状态也会立刻对玩家发起进攻。"计时器"一栏的作用在于计算巡逻的时间以及丢失玩家的时间:当敌人来到区域边缘,他会歇息片刻后再重新回头开始巡逻,而丢失玩家时间则是当敌人追逐玩家的过程中如果一段时间内无法检测到玩家的位置则重新回到巡逻状态。

三种状态,不过和我说的显然不是很一致,因为我们这个demo里的游戏素材并没有包含专门的野猪进行攻击的动画,所以我们就将追逐状态和攻击状态合为一体,然后加一个当前状态来方便操作。我们这里可以看到一个名为BaseState的类,这个类就是一个专门列举敌人状态的类。

我们把这个类定义为抽象类,这样在非抽象类的子类中要求实现其方法,我们可以看到定义了四个方法:进入状态,逻辑帧更新,物理帧更新,退出状态。

能够清楚地看到,我们BaseState类中的四个方法其实与MonoBehavior中的周期函数是一一对应的,这样也方便我们进行管理。而我们的Move方法,和玩家的Move方法类似,对于横向的左右移动,我们直接修改刚体的速度即可。我们也同player中的move方法一样,将move放在物理帧更新的函数里,这样能保证移动的函数不会因为电脑性能的问题产生波动。

我们先做一个野猪作为我们的第一个敌人,首先需要继承Enemy类。

由于我们在Enemy脚本中将我们的Awake写成了一个虚函数,所以我们需要在Boar类中重写它(为什么要在父类中将awake设置为虚函数呢,因为各个子函数可能有着完全不同的初始化需求)。

然后我们来做野猪移动的动画。同样的:添加animator,添加野猪的巡逻动画片段。

现在我们该为我们的敌人写不同状态的代码了

再次回顾我们的BaseState中的抽象方法。

我们首先来写巡逻状态,既然我们这个类不是抽象类,那我们就必须要实现所有的抽象类中的方法。

但是在这之前我们需要先把已有的状态全部作为枚举变量列出来,为什么是枚举变量?因为枚举变量更直观,也更直接,更便于维护:枚举变量的内容是不可修改的。然后这里状态的枚举变量和之前的BaseState类有什么关系和区别吗?其实按理说是完全没有关系的,BaseState类是我们用来具体地写每个敌人的状态下执行的内容的,而枚举只是我们用来表明状态的一个名字一样的东西。

现在来看我们的代码:

其中的FoundPlayer函数在Enemy中已有定义:

调用了Physics2D中的BoxCast函数进行判断。

然后是追逐状态的实现:

检测到玩家后就切换状态为追逐状态,播放run动画。两种实现的状态在逻辑帧更新中都加入了如果撞墙则转身的判定。然后我们再回到我们的Enemy脚本中添加状态切换的函数:

其中的=>是一个箭头函数,可以更简单地进行赋值。

相同的,我们的敌人的受伤与死亡也可以这样来做。

这是我们Enemy类中的有关受伤和死亡的函数,我们得到攻击者的Transform,并且定义一个协程函数:一种可以中途暂停的函数,具体来说,我们这个函数一开始执行就会执行给刚体添加一个力的语句,之后会暂停0.5s后我们再执行isHurt=false。更具体地说的话,我们的yield return关键字将控制权返回协程管理器,然后WaitForSeconds就是协程的一个类似命令的东西。

OnDie函数中我们选择将gameObject的层级调整为2的目的是让他从当前我们的层级彻底移除,这样我们的碰撞检测就不再会发生。

UGUI

首先是我们基础的UI组件部分的教学,不涉及代码的部分我还是就不放进笔记里了,都是一些经验性的东西。但这里我希望先介绍一个跟UI相关的最重要的东西:

EventSystem,我们所有跟UI相关的东西的交互的发生和执行都是基于这个EventSystem来做的。

我们可以看到这个EventSystem中有一个自带的InputSystemUIInputModule的脚本,这个脚本是当我们确定使用New Input System时我们的EventSystem生成的,其中他的很多接口是与我们的Input Action Assets一一对应的。

StatusBar

这里我就简单地介绍一下:如何利用代码控制我们玩家状态的变化,更具体地说,在这个demo里就是玩家生命值的变化。

根据之前的教程,我们应该做出了这些东西,其中玩家的血条其实是一个血条框加一个纯绿和一个纯红的条组成的,这两个Image类型的条我们修改他们的ImageType为Filled,这样他就变成了填充型的图片,我们可以滑动来改变他的外观。

那现在我们要做的事其实就非常简单了,我们只用写一个脚本来得到Image类型的两个条并实时地更新他的Fill Amount参数即可。

红色条和绿色条,其中绿色条与实际的生命值保持一致,在Update中一旦发现红色条与绿色条的Fill Amount不同就更新到相同的值,这样就可以实现红色条延迟于绿色条的效果。然后我们在这里实现了OnHealthChange方法。

那现在我们只需要想办法得到人物的属性(Character)中的数值就可以了,我们当然也可以选择使用引用的方法,但是当出现跨场景的情况时,我们的正常引用方法容易报错。所以我们在这里第一次引入了SO(ScriptObject)的概念。

什么是SO呢?SO就是一个可以永久保存在项目中的资产性的文件类型,他的文件后缀是.asset,这也说明了他已经是资产一样的东西,会跟着项目一直存在。我们可以在SO保存可能会跨场景反复使用的诸如变量、方法、函数甚至事件等,是一个非常好用的东西。但他并不是十全十美:ScriptObject不是引用,我们查询他的来路与用途相对来说比较麻烦。

我们首先来写一个脚本,比如第一个SO脚本,我们希望传递角色的属性也就是Character,那么我们就把这个SO文件命名为:CharacterEventSO

可以看到与我们之前的脚本的不同之处:我们的脚本继承的不再是MonoBehavior而是ScriptObject,我们在最前面加了一句:[CreateAssetMenu(menuName="Event/Character")],这样我们在Asset界面右键Create就可以看到Event以及Character选项了,这时候我们就能创建一个SO文件。

然后我们首先创建了一个UnityAction:Unity委托的一种,其实这个Action与我们的Event有些相像,但是简单地说,我们的Event是在Inspector窗口添加方法,而Action则是直接在代码里执行的。但无论是Event还是Action,都只是基于delegate(委托)的一种封装实现,运用性最广的依然还是delegate。

话扯远了,我们继续完善我们的CharacterEventSO脚本,这里我们定义一个action命名为OnEventRaised,然后再马上写一个触发委托的方法(名为RaiseEvent),然后我们对OnEventRaised委托进行检查,如果不为空则执行订阅该委托的方法。

可问题是我们现在将委托和触发放在了同一个脚本里,我们要如何才能将这两个方法分开使用呢。

在Assets中创建文件夹DataSO/Events,在其中添加我们的CharacterEventSO类型的SO文件:

然后我们就回到一开始的部分:我们需要去读取玩家具体的属性(Character),那么我们当然需要在Character脚本中去添加一个事件来进行广播。

可能大家和我一开始也一样有一些晕,我们不是已经在CharacterEventSO文件中定义了一个委托了吗,为什么还需在Character中添加一个事件呢?本质上其实当然可以不用这么麻烦,但是SO文件作为一个可以持久化存在的资产性文件,在更复杂的项目里会更方便,所以我们这里主要是为了学习用法。本质上我们的做法相当于在已有的一个喇叭来广播触发订阅事件的方法的基础上又多加了一个喇叭,老喇叭传给了新喇叭,新喇叭再来广播。好处在于新喇叭更结实,声音也更远。

那么这个老喇叭是谁呢?其实就是我们Character脚本中的UnityEvent:OnHealthChange。

在我们之前的笔记中已经看过一次这句代码,但是并没有具体展现订阅他的东西。

打开Inspector窗口:

我们用Character Event SO订阅On Health Change的事件,这样我们就可以调用CharacterEventSO中的RaiseEvent方法了。

这个是我做的一些总结,希望对大家有帮助,过程是繁琐了点,但是对将来更复杂的项目来说是有用的。

那现在我们的SO文件订阅上我们Character中的UnityEvent了,谁来订阅我们SO文件中的Action呢?那当然是我们真正的主角:代表玩家血量的Image类型的图像了:如此复杂的流程,只是为了能控制两个色块的长短,令人感慨。

当然我们这里为了统一调用UI的好习惯,新建一个UIManager脚本来统一管理UI。(Manager模式是非常重要且有效的一个设计模式,通过这个模式我们能集中且有效地执行代码且更直观)

定义好之后,我们首先获取之前的PlayerStatBar类并且监听CharacterEventSO。

可以看到,我们的OnHealthEvent监听了CharacterEventSO类型的healthEvent中的Action类型的OnEventRaised,现在我们来具体实现OnHealthEvent。

非常朴素的两句话即可:但是注意我们的这个订阅了Action的OnHealthEvent方法的参数列表要求是必须和我们的CharacterEventSO文件中触发委托的函数参数列表相同:我们可以这样设想:我们要触发委托就必须调用触发委托的函数,而我们订阅委托的函数只要委托触发就一定会被调用,二者是一个一脉相承的关系。

至此我们就使用这个新加的SO文件实现了通过代码控制人物血量的变化,让我再来梳理一下:

相信这样就清晰不少了。

SceneManagement

Sign

虽然这个小节的名字是简简单单的信号,但其实在这里我们要实现可交互物体的交互逻辑(我们客户端的最本质工作)。

课程中已有的美术素材可以让我们做一些诸如宝箱之类的物品交互,但毋庸置疑,各个场景之间的传送交互才是相对来说比较重要的一环。我们就先来实现当我们走到可交互物品旁边的时候,弹出提示告诉我们物体可交互的部分。

我们就先用宝箱来练练手吧,我们把宝箱放进Scene中。

当然,既然都说了是可互动物品,我们最好专门想办法将这类物品与不可互动物品做一个区别,我们可以添加一个接口来专门做可互动物品的功能。

我们可以介绍一下Interface的一些作用:他是一个完全抽象的类,只允许在接口中声明而不允许实现,这样所有继承这个接口的类都必须实现声明的内容。比如我们的这个脚本:我们声明了一个TriggerAction,那么所有继承这个接口的类都必须实现这个方法。

比如我们的宝箱:

这里的sprite类型的变量其实就是宝箱打开和关闭的图片,为什么是sprite类型而不是image类型?sprite是Unity中的基础类型,是一种用来显示图像的资源类型,与UI关系不大;而image算是一个unity组件,他必须依托于Canvas,与UI强相关。那显然这里我们的宝箱虽然本质上也只是进行图片的切换,但是他在游戏里的属性是"物品"而不是"图片",所以使用sprite属性。

也可以看到我们实现了IInteractable接口的方法TriggerAction,以及OpenChest方法,当宝箱被打开后就会变成一个无tag的gameobejct了。

为什么这里要转换成无tag呢?因为我们需要用tag,或者说,调用Collider2D的CompareTag方法,这样我们可以利用碰撞检测并检查tag来执行特定的函数。

新建一个脚本名叫Sign,挂载在Player上。

我们来看生命周期函数中做了哪些事,我们首先需要一个提示图像的GameObject并获取他的动画组件,然后我们进行互动时需要读取键盘输入,所以我们需要PlayerInputControl组件,在这里我们使用了一个PlayerInputControl自带的Action:playerInput.GamePlay.Confirm.started,用OnConfirm方法订阅这个Action。

我们在这里又使用Unity自带的OnTriggerStay2D与OnTriggerExit2D方法来维护我们的bool变量canPress,同时这个bool变量为true才能执行OnConfirm函数,这个函数里执行我们可交互物品的IInteractable的接口方法:宝箱从关闭的sprite变换成打开的sprite。

Addressable & Scene Loader

我们实现了基础的可互动物体了,那现在我们需要执行互动后的逻辑了。上节提到的互动宝箱本身只是抛砖引玉,我们真正需要互动的物体应该是现在的传送机制:比如我们做了一个山洞口,那我们需要实现:当我们来到山洞口时,弹出可以互动的提示,并且在执行确认操作后实现场景的切换。

那这个过程就涉及到很多东西了:我们需要保存所有场景,然后在切换场景时,加载我们需要的场景。在真正的代码部分开始之前,我们来先介绍一个非常好用的东西:Addressables。

这个是人工智能关于这个组件的介绍,可以看到他的关键点在于加载游戏内资源。

我们首先需要去安装这个包,具体怎么安装不再赘述,反正就是打开Package Manager的事。

下载好后,我们可以在window下的asset management找到addressables,我们点击groups。

就能看到这样的界面,当然第一次打开不会有这个group,取而代之的应该是一个settings没有配置的提示。

你create一个addressable settings后,在assets文件夹中就能看到自动生成的这个文件夹和一个SO文件来存储你打包的方式。

这样就可以来到这个界面了:

我们如何把我们想要保存的资源加入这个Addressables Group中呢?

我们打开之前创建的scene,可以在Inspector中看到这里有个addressable的选项,勾选后就能选择放进哪个具体的addressable group中。不过值得注意的是:加入addressable后我们在Unity自己的Building settings中就不能加载这些场景了。这也非常容易理解:我们通过addressable进行打包后这些场景已经变成了另一种方式的资源,自然不会通过传统的building settings加载。

addressable显然不仅仅适用于场景,事实上,只要是需要加载的资源,我们应该都可以通过addressable来进行打包。比如我们的敌人:我们可能需要在多个不同的场景中不断生成我们的敌人,那我们首先会考虑将我们的敌人做成:预制体(prefab)。

生成预制体的方法非常简单:我们将Hierarchy中的物体直接鼠标拉到我们的asset处他就会自动帮我们生成预制体。预制体是一个可以反复使用的游戏模板,且我们修改预制体的话,所有Hierarchy中的属于预制体的物体都会同步改动,非常的方便。

可是事实上,哪怕是放在asset中的prefab,在切换不同场景时,我们传统的打包方式还是会出现重复打包的过程。

所以,为了进一步提高效率,我们来使用Addressables。

于是我们把我们的预制体也放进了Addressables中。

OK,现在我们完成了场景加载的前置工作了,我们需要开始着手写代码了。

首先,我们要有一个确认场景切换时参数传递的SO文件,还记得我们的SO文件的作用吗?他也是一个存储性的资产文件类型,我们一般会写一个SO文件的脚本,这其中包含一个委托和触发委托的方法,然后我们还会实际生成一个SO文件,这个文件用来订阅我们Manager脚本或者其他脚本的定义的事件或委托。在这里,我们会有一个SceneManager的脚本来统一管理所有的场景切换的事宜,显然这个脚本里也会定义场景切换的事件,我们就用我们的这个SO文件去订阅他的事件即可。

与之前的CharacterEventSO.cs类似,我们这个SO文件取名就叫SceneLoadEventSO。

这个SO文件我们用来确定我们场景切换前加载的位置,场景切换后去的坐标(其实还有一个是否产生屏幕渐隐渐出的效果,不过这个效果我没有放在笔记中),然后我们还需要一个SO文件来确认切换的场景,而这个场景就是我们之前说的存储在addressables中的场景,我们将这个SO命名为GameSceneSO。

如何理解这个脚本的代码呢?

首先我们需要调用命名空间:using UnityEngine.AddressableAssets,并且在类中定义一个AssetReference类型的变量,这个变量是一个引用变量。显然我们需要的是Addressable中的场景,所以这个引用就命名为sceneReference即可。后续还有一个SceneType类型的变量,这是一个我们自己创建的枚举变量,方便我们对不同场景做出区分。

我们的场景有两种类型:一种是菜单,而另一种就是我们的地图。

定义好SO文件的脚本后,我们生成一个具体的SO文件:

能够看到现在的GameSceneSO中:

他就有这两个公共变量了,我们这个时候就可以将我们的放在addressable中的scene挂载上去了。

现在我们来做一下我们通过交互实现传送的物体。

我们假设来到这个位置后就可以通过按键交互实现传送。

那我们首先需要一个脚本实现传送的功能。

可以看到他使用了上一小节提到的接口IInteractable,且他定义了一个Vector3变量代表传送后去的坐标,一个GameSceneSO代表传送去的场景,以及一个我们刚提到的场景加载事件的SceneLoadEventSO。我们实现接口的方法:执行SceneLoadEventSO中的触发委托的函数。

那现在我们又要倒回SceneLoader脚本中去看看是哪些函数订阅了我们的委托了:

这是我们的SceneLoader中监听的事件。

不难看到,我们要使用这个SO的频率还挺高,在这里我们主要说说订阅这个委托的方法:OnLoadRequestEvent。

我们先不看协程方法中的存档方面的方法,能够看到我们这个方法中又调用了一个名为LoadNewScene的方法。

而这段代码才是我们真正功能实现的地方:SceneToLoad是一个GameSceneSO类型的文件,我们调用其中的AssetReference类型的sceneReference中自带的方法LoadSceneAsync。

这个是我们的unity手册中的LoadSceneAsync,可以看到,它可以在后台异步加载场景。

这样的话,我们就完成了基础的传送功能,让我再来梳理一遍:

总的来说就是这些过程,让我们实现了传送的效果与场景的切换。

LoadSceneAsync.OnCompleted

现在我们完成了基本的场景切换的功能,但是很多场景切换后的逻辑我们还没有进行处理,所以我们现在来做。

我们可以回忆一下我们之前做了哪些事,除去复杂的诸如事件监听的过程,我们的核心代码无非一句利用GameSceneSO中定义的AssetReference类型的变量自带的LoadSceneAsync方法来加载场景。然后我们的监听的另一个事件:SceneLoadEventSO中会记录我们切换场景前的位置、切换场景后的坐标。这是我们之前做的工作,现在让我们来完善。

显然,我们这个时候需要一个进行广播的事件来做这个功能会更合理:我们向整个项目广播,告诉代码我们已经切换完场景了,这个时候就可以把一项项方法悉数调用了。

我们该选用之前的哪个SO文件呢?还是直接使用UnityEvent?这里就不得不细说一下SO文件相比起直接的UnityEvent的优势了:UnityEvent一般是单独挂载在单个对象上的组件,它无法跨场景、跨脚本地调用;SO文件中则可以包含UnityEvent,这样所有监听这个SO文件的对象无论哪个场景都可以去监听到这个Event,且SO文件更便于维护,更统一。

那该选择之前的哪个SO文件呢?

我们之前的SO文件中,都有在Event前面加一个前缀来表明用途,比如我们的CharacterEventSO,SceneLoadEventSO,但是显然并不是所有的SO都会有如此具体的需求,我们可以创建一个更通用、泛用的SO文件。

命名为VoidEventSO,维持了和之前SO文件一样的框架。

然后我们在SceneLoader中对这个事件进行广播:

我们在上一节的内容中已经完成了LoadNewScene的方法:

可以看到我们获取了LoadSceneAsync的返回值,这是一个什么样的值呢?

返回一个确认场景切换是否完成的值,名为AsyncOperation。

我们在代码手册中进一步点击查看:

我们能看到他是继承自YieldInstruction的一个值,如果认真看笔记的同学应该还有印象:这是我们之前的协程函数中yield return 返回的值的类型。

不过现在我们不考虑这些东西,让我们把目光聚集在它自带的Events:Completed。

操作完成时调用的事件,显然,这与我们的内容不谋而合,倒不如说,就是因为有这个事件我们才能这么轻松地完成这一小节。

在SceneLoader里加入一个新的函数方法用来监听事件。

我们将这个函数方法命名为OnLoadCompleted,他参数列表中的参数类型正是我们的异步操作对象。我们在这个函数中将场景、坐标都给到现在的场景和坐标。同时我们检测跳转的场景是不是菜单Menu,不是的话就触发afterLoadEvent中触发委托的方法。这个afterLoadEvent正是我们之前添加的VoidEventSO类型的变量,我们使用它来进行广播。

在原本的课程中,订阅这里的广播的事件主要是进行获取摄像机边界,实现场景切换淡入淡出效果等,在这里我偷个懒,不赘述了,结构性的东西有写出来即可。

这里我们来到了主菜单的制作部分,主菜单的UI制作还是老样子,我也就不介绍了,本来这也是一个各显神通的部分,基本的工具使用也都非常简单,我还是主要来介绍代码部分。

这里放一个我的主界面的效果图,其实就是我文章最开始的那张图:

显然,这个菜单主要的代码部分或者说逻辑交互部分就是这些按钮,我们需要新游戏,加载游戏和退出游戏三个功能。而这三个功能中Quit是最简单的,因为我们的的Application中已经自带了Quit的功能。

Application是一个很大的类,我们在这里就不展开说了,只要调用头文件UnityEngine就可以使用。我们这里主要是调用Application类中的Quit函数,这个函数可以帮助我们直接退出播放器,也就是退出Game。

于是我们的Quit功能就实现了。

显然,菜单是关于点击的场景,涉及到点击或者输入就需要我们读取设备输入,也就是InputSystem。

在这里我们定义的变量类型中关于Input的有两种,一种是InputActionAsset,一种则是InputAction,这两种变量类型有什么区别呢?让我们复习一下,再一次打开我们的PlayerInputControl:

首先我们知道这个PlayerInputControl其实就是我们的InputActionAsset类型的实例,那么其实也不难猜出:InputAction对应的就是我们在这个界面中的Actions中的成员的类型。

我们打开UI的Actions,可以看到一个名为Submit的Action,是确认的意思,它绑定的是我们鼠标的左键。于是我们在脚本里调用它。

这里有两个知识点:一个是我们订阅了InputActionAsset中InputAction类型自带的事件:performed,表示这个这个InputAction被执行的时候。而我们订阅这个事件的方法则是确定当前的EventSystem的当前选择GameObject不为空时我们触发这个按钮的onClick事件。

第二个知识点自然就是我们的EventSystem了,我们在之前涉及到UI方面的知识点总是避而不谈,但是EventSystem我其实已经介绍过一次,但好事不介意说两次:

能够看到,EventSystem可以帮助我们选择首先选择的游戏对象以及按钮之间的导航等与UI息息相关的事。

加载游戏的事涉及到了数据的保存和读取,那将是我们这个游戏的最后一个难点,我们在这里先完成新游戏的逻辑吧。

显然,新游戏是一个事件,且涉及到了跨场景,所以我们需要用一个SO文件给到按钮的onClick事件。

我们并不需要这个SO返回我们什么,所以依然用之前的VoidEventSO来做即可。

新游戏其实也非常简单,我们只要点击New Game,然后切换到第一个关卡的场景即可。但是我们可能还忘了一件事就是:我们游戏一开始启动的时候,还需要先来到我们的菜单才行。

打开SceneManager下的SceneLoader脚本:

监听我们的newGameEvent,定义了玩家在菜单Scene中的transform

在SceneManager开始执行的时候,我们首先加载到菜单。

定义一个新方法名为NewGame订阅newGameEvent事件。

NewGame方法内容如下:将将要去的场景切换为firstLoadScene(Start中执行的是menuScene),也就是我们新游戏的第一关,加载第一关的Transform。

这样我们的菜单功能中的NewGame和Quit功能都做好了,至于最后的Load Game,那就涉及到我们数据的保存和读取了。

Data Save & Load

Data Save & Load

我们首先来做一个基础的存储点,让我们可以在这里实现存储的效果。

我们就用这个石头作为我们的存储点,当玩家来到此处时就可以进行存储。

那首先,我们需要保证这块石头是一个可交互的物品,也就是挂载在这块石头上的脚本需要实现IInteractable接口并实现TriggerAction方法。然后我们需要新建一个SO文件,在SO文件里定义保存这一委托。

我们给我们的SO文件起名叫做SaveDataEvent,它是VoidEventSO类型的。

现在做好了基础的存储点了,我们需要开始考虑:我们具体需要存储哪些值了。

首先是人物的坐标与敌人的坐标,还有人物的血量与敌人的血量,当前所在的场景,可交互物品。

显然我们需要一个专门针对需要保存的物体的接口,我们给这些物体接口,当保存的时候我们执行实现这些接口的方法,这样各个物体就可以各司其职。

我们写一个接口名为ISaveable。

我们先不具体的介绍这个接口的实现方法,而是先去介绍我们的更重要的脚本:我们的Manager层面的东西,关于数据的保存,我们写一个脚本来统一管理,命名为:DataManager。

首先我们需要聚焦在public static DataManger instance,这个语句就是将我们的DataManager设置为单例模式的意思。什么是单例模式呢?

简单地说,单例模式下的类只有一个实例,所有跟这个类有关的部分都是与这个实例直接有关。

我们需要在awake中进行检查:如果有别的实例了就删除这个实例,否则就使用这个作为我们的实例。

我们再回到这个界面,可以看到DataManager主要监听了两个事件,一个单例模式的语句,有一个存储类型为ISaveable的链表以及一个Data类的变量。

前面四个语句都是比较好理解的,这个Data类是怎么一回事呢?

这是我们Data类的全部内容,我们有一个string类型的变量记录保存的场景,有两个字典类型的变量分别存储角色的坐标和血量,定义了一个方法名为SaveGameScene,这里调用了Unity一个自带的工具JsonUtility中的ToJson方法。

能够看到,我们通过这个ToJson函数可以将obj类型转换Json形式,具体的Json格式是什么呢?

于是我们这样就在SaveGameScene中就可以把我们的场景转为Json格式,更具体地说,其实是string类型。

回到我们的DataManager脚本,我们继续后面的内容。

首先是调用MonoBehaviour中的生命周期并在其中将方法Save和Load分别订阅SaveDataEvent和LoadDataEvent事件。然后我们可以注意到,虽然我们的DataManager没有直接继承ISaveable接口,但由于我们的链表中存储的是ISaveable类型的数据,所以我们依然需要在DataManager中实现该接口的方法。

事实上,让我们回到我们的ISaveable中:

我们都知道接口中按理说是不能直接实现方法的,但是也有一些特殊的情况:简单地说,我们的接口中的方法可以给一个简单的默认实现方法,这样所有实现该接口的类都会有这个方法。而在我们的接口中,可以看到我们的默认接口实现方法就是调用DataManager中的注册和注销方法:也就是在saveableList中添加和删除作为参数的ISaveable类型的变量。

最后我们来到我们的Save函数与Load函数吧,他们的内容就是遍历saveableList并通过ISaveable接口的GetSaveData方法获得可保存物体和LoadData方法实现加载物体。

这样我们就先过完了一遍代码,但是现在还存在一个小小的问题:我们可以去保存物体了,可是每个物体之间要如何区分呢?我们第一时间想到的可能是name,毕竟我们每个物体之间都存在Name的差异,可是假如我们有很多重复的敌人呢?比如我们有三头猪,这该怎么办呢?

事实上,unity早就帮我们解决这个问题了:GUID(Globally Unique Identifier)是一种128位的标识符,用于在计算机系统中唯一标识信息。而我们就要利用这个GUID,来给每一个不同的物体独特的编号来进行区分。我们需要一个代码挂载在每一个需要保存的物体上,让他生成独特的编号并让我们的DataManager能够识别它。

我们的这个代码名为:DataDefination。

可以看到DataDefination类中的成员变量包含一个string类型名为ID与一个我们自己定义的枚举类型PersistentType名为persistentType。

这个枚举类型中包含两种枚举变量:读写和并不长久存在。我们用这两种枚举变量来判断需要的数据是否会在后续作为被读取和写入的对象。

然后我们可以看到一个名为OnValidate的函数,这也是MonoBehavior中的方法:

似乎是纯英文呢,简单地说就是:当脚本被加载或者是当挂载脚本的对象的Inspector中的值发生变化时我们会调用这个OnValidate函数,翻译为在编辑器调用的函数。

回到我们的函数内容,如果这个数据类型是要求读取和写入的,我们就调用System中的Guid中的NewGuild方法并转换为string类型后给到我们的string类型的ID变量。(这里的Guid生成方法的背后原理似乎相当复杂,在代码手册中没有找到)

现在我们定义好了DataDefination函数,可以为每个物体生成独有的Guid后,我们还需要去我们需要保存的物体自带的脚本中去调用这个生成Guid的方法。

虽然我们之前一直在重复的宾语是需要保存的物体,但其实我们转头一想,真正需要保存的物体不就是我们的诸如宝箱,敌人,还有我们的玩家。而宝箱在我们之前的操作中,我们已经通过标签来设置他的可互动性了,且宝箱在我们的案例中是唯一的,所以我们不对它进行过于复杂的操作,于是需要保存的物体主要就是我们的玩家和敌人,而这两个对象的共同点就是Character脚本。

首先我们实现ISaveable接口,当然,我们还得实现接口方法。

然后我们在这里实现接口方法,第一个是保存方法:我们检查我们的字典中是否包含我们通过GetDataID方法得到的ID,如果有我们进行transform和curHealth的更新,否则我们就添加进我们的字典里。加载方法也很简单,我们反着从字典里读取我们需要的transform和curHealth即可。

然后是我们后续去接口里声明一个GetData函数,这样保证每个实现我们ISaveable接口的方法都必须执行一个GetData函数来获取Guid。

具体的效果如图所示:

现在让我们从头开始整理一下目前位置Data Save和Load的功能具体实现的流程:

可以看到这整个过程非常的复杂,我们以DataManager为中心来向四周发散。首先DataManager订阅了SaveDataEvent------由SavePoint中定义的SO文件,这个SavePoint实现了IInteractable接口的方法TriggerAction:用于触发SaveDataEvent中的触发委托的函数。而DataManager中订阅SaveDataEvent的方法具体来说就是名为Save的函数:

我们遍历变量类型为ISaveable的链表,挨个执行ISaveable中的GetSaveData函数。

然后就转到我们的ISaveable接口:

可以看到这个接口的内容很多,其中两个方法的默认实现还是在DataManager中实现的。

而我们在ISaveable中还可以看到一个DataDefination类和一个Data类:一个是用来专门获取每个物体具有唯一性的Guid,一个则是专门用来定义需要保存的内容。

DataDefination:

这个脚本本身并没有太多可说的内容,最重要的就是关于Guid的概念。

Data:

这个脚本中明确定义了需要保存的数据:场景、坐标与血量值。

最后是我们订阅LoadDataEvent的方法:Load,我们同时还会检查键盘的L键,被按下就会执行Load函数:

可以看到都是在调用ISaveable的方法,那Save和Load中的调用ISaveable接口中的方法并没有默认实现,去哪里实现呢?

答案是在Character中,事实上,我们的Save和Load本身就是用ISaveable类型的变量作为函数参数,所有调用这个函数的传参本身就需要自己实现这两个方法,这里我们用Character来示例。

以上就是我们的Data Save和Load的内容了,可以说篇幅非常的长,内容也非常的多。

Gameover Panel

现在让我们来制作游戏结束的界面,同之前的开始界面一样,关于基础的UI部分我就不细说了。

能够看到我们制作了两个功能:重开与返回菜单。(这个GameOver是标题)

首先是重开,我们可以设想我们需要的过程:我们需要读取之前保存的列表的数据,如果没有的话我们就直接从最开始开始。如果有的话,我们得执行Load事件,其中肯定包括了场景的切换,也就是说我们的重开事件必定同时包含DataManager的调用和SceneManager的调用。

还记得我们按钮自带的OnClick事件吗?我们可以在这里订阅这个事件来执行我们的方法,又或者是我们的SO文件。

可以看到,我们分别在两个按钮的OnClick事件中添加名为LoadDataEvent和BackToMenuEvent的SO文件,然后在我们的SceneManager和DataManager中去订阅这两个事件。

然后分别在这两个脚本中添加方法去订阅这两个事件。

回到菜单我们只需要进行场景切换即可。

SceneLoader中让OnBackToMenuEvent方法订阅我们的backToMenuEvent中的OnEventRaised委托。这个方法的内容也很简单,我们切换场景并执行loadEventSO中的触发委托事件(加载)。可能有人已经忘了,我们再来回顾一下这个loadEventSO的类型SceneLoadEventSO:

这里我们传入GameSceneSO,Vector3以及bool类型的变量作为传参。

然后让我们看看DataManager中的LoadDataEvent。

是的,这是我们刚刚在上一个小节介绍的内容。

很好,现在我们有了订阅这两个事件的全部内容了,可是我们似乎还忽略了一个更宏观的事:我们要如何调出这个游戏结束的菜单呢?

显然,我们只有在死亡时会调出这个菜单,这就回到我们之前的制作角色死亡的部分了。而既然涉及到了弹出菜单的操作,那自然也离不开UIManager的操作。

我们先来操作UIManager吧:

显然,这些就是与我们的GameOver Panel相关的事件与组件。

但其实,在这里我们的unloadedSceneEvent也是我们制作GameOver Panel需要监听的事件,如何理解呢?我们选择返回菜单后,我们显然需要卸载现在的场景来到菜单。所以我们现在为了实现角色死亡->调出结束菜单->实现重新开始和返回菜单键,我们的UIManager中需要监听三个事件,并分别订阅这三个事件:

游戏结束我们调出我们的gameOverPanel,如果选择返回菜单我们会立即场景切换到菜单且UI进行调整,如果选择重新开始游戏等同于我们进行加载,那么一切按照加载的操作来,我们还需要关闭这个gameOverPanel。

这样我们的GameOverPanel就制作完成了。

Pause Panel

现在让我们看看暂停面板怎么制作吧,既然我们都已经做出了死亡面板了,那么暂停面板也不会有特别大的差异。

首先依然是基础的UI摆放。

其实我都没做啥东西,就放了个可以控制音量的滑条。关于这个控制音量本身并不难,显然Unity已经帮我们做好了接口。

这是我们滑条的内部结构,可以通过这里看到主要的内容包括一个填充区域和一个滑条头部(其实就是滑条的那个圆),显然我们主要的目的是同步填充区域去控制我们的音量。

新建一个GameObject并为他添加Audio source组件,我们可以看到这样的画面:

分别是声音片段和输出,这里我们的输出是从哪来的呢?

我们找到AudioMixer打开就可以看到:

详细的操作大家可以去自行搜索,现在让我们先打开代码部分吧:

显然,这三个事件是我们控制音量的重点。

能够看到我们可以直接调用AudioMixer类型的实例mixer的GetFloat方法与SetFloat方法来对音量进行调节。

进行音量调节的方法我们已经有了,现在问题来到了:我们如何调出暂停面板?

显然,这将完全由我们的UIManager来执行:

我们在代码里手动订阅了我们的设置按钮的OnClick事件(用TogglePausePanel方法)。

我们的TogglePausePanel方法非常的简单,我们会通过调节Unity的Time中自带的timeScale来实现暂停的效果。

Conclusion(for now)

目前第一版笔记先写这么多吧,我一开始只是想复习一下大体的框架,没想到越复习东西越多,都快三万字了。其实我这第一版笔记可以说非常粗糙,基本只讲代码部分,且很多代码实现的逻辑都没有细讲,只是冲着梳理框架去的,然而连梳理框架也没有做很好,完全没有顺理成章的感觉。时间紧急,毕竟马上就要过年了,我后续还要整理出好几个游戏demo的笔记,这个笔记暂时就这样吧,我后续会不断地精进并维护这个笔记的。

我们目前梳理了四个大块(其实是五个,但Enemy这一块除了在基本的移动逻辑上有一些变化以外其他部分都是在Player模块已完成的),可以粗略地分为基本的玩家实现、玩家UI和属性的制作、场景切换、数据保存和加载。我们介绍了很多蛮新的概念(现在还新吗),比如ScripctObject文件,Addressable文件,基本的事件、委托,接口,枚举,JsonUtility,生成Guid,单例模式等,然而事实上课程上很多东西我都还没有介绍,这个我也希望大家自己去观看原课程进行学习。

安装Unity引擎和代码编辑器|Unity2022.2 最新教程《勇士传说》入门到进阶|4K_哔哩哔哩_bilibili

这个是原课程的链接。

然后是有关demo的链接,我之前生成的.exe文件貌似已经被删除了,现在这个电脑似乎由于盘里东西太多,每次一运行这个demo就卡死然后强退呃,等我有时间换台机子先跑跑修修bug,做好了放在某平台上再放链接吧,反正后续还要维护的。

以上,话说真的有人会看到这里吗...anyway,如果有人看到这里,那么我衷心地对你表示感激,并祝福你也能在自己追求梦想的路途上一帆风顺。

Thanks

相关推荐
晓数1 小时前
【硬核干货】JetBrains AI Assistant 干货笔记
人工智能·笔记·jetbrains·ai assistant
我的golang之路果然有问题2 小时前
速成GO访问sql,个人笔记
经验分享·笔记·后端·sql·golang·go·database
lwewan2 小时前
26考研——存储系统(3)
c语言·笔记·考研
搞机小能手2 小时前
六个能够白嫖学习资料的网站
笔记·学习·分类
nongcunqq3 小时前
爬虫练习 js 逆向
笔记·爬虫
汐汐咯3 小时前
终端运行java出现???
笔记
无敌小茶5 小时前
Linux学习笔记之环境变量
linux·笔记
帅云毅6 小时前
Web3.0的认知补充(去中心化)
笔记·学习·web3·去中心化·区块链
豆豆6 小时前
day32 学习笔记
图像处理·笔记·opencv·学习·计算机视觉
nenchoumi31196 小时前
VLA 论文精读(十六)FP3: A 3D Foundation Policy for Robotic Manipulation
论文阅读·人工智能·笔记·学习·vln