前言
在Unity官方教程 2D Roguelike(3):移动逻辑中,我们完成了基本的移动逻辑,并且可以控制大胡子进行关卡内移动。这一节我们主要完成的内容是:
- 击破障碍墙开辟道路
- 实现被敌人攻击掉血的逻辑
- 通过拾取食物和饮料增加生命
- 移动到Exit进入到下一关
本节你将学会什么?
- 设置动画状态转换
- 代码控制触发动画状态
- 控制台debug打印信息
- 延时调用方法
- 代码控制场景加载
在击打障碍墙和被敌人攻击的时候,我们期望大胡子除了扣血掉血之外,还有明显的动作变化让我们辨别。所以接下来我们先实现角色动画的转换功能。
一、实现角色动画转换
角色动画的转换功能,指的是两种情况:
- 角色遇到障碍墙并且进行攻击,这时候角色的动画从idle切换到chop,并且在chop动画结束之后切换回idle。
- 角色遇到敌人并且被敌人攻击,这时候角色的动画从idle切换到hit,并且在hit动画结束之后切换回idle。
转换逻辑主要是通过动画控制器实现。
第1步:增加动画转换的触发参数
我们双击Animation Controllers文件夹下的Player打开Animator面板。
在Animator面板,我们可以看到一共存在3个动画状态:PlayerIdle、PlayerHit、PlayerChop,其中橙色的为默认状态,也就是当游戏运行的时候角色会播放此动画。
在设置动画状态之间的转换关系之前,我们需要添加一些参数作为转换的触发条件。首先选中左上角的Parameters按钮,点击下方输入框的右侧+号,选择Trigger,新增一个参数playerChop。
使用同样的方法再增加一个命名为playerHit的参数。Trigger类型的参数类似于布尔值,当选中激活的时候也就是为真,条件满足,则进行状态间的切换,然后立刻重置。
第2步:设置动画状态转换
我们来看看如何设置待机动画和劈砍动画的转换。右键PlayerIdle,选择Make Transition,再点击PlayerChop,可以看到两个状态之间建立起了连接,箭头方向代表是从PlayerIdle状态转换为PlayerChop。
当然,劈砍动画读完之后是需要切换回待机的,所以我们还需要右键PlayerChop,再次选择Make Transition,创建一条返回到PlayerIdle的线。
选中高亮从PlayerIdle出发到PlayerChop的线,我们会发现连线也是有属性设置的,可以设置比如是否有打断时间、过度持续时长、触发转换的条件等。
我们只需要设置以下几项:
- Has Exit Time:接受打断时间,勾选后要等当前动画播放到指定的地方,才会接下一个状态;不勾选的话,就允许动画随时被打断切换到下一个状态。在这里,因为我们需要在遇到障碍墙的时候立刻劈砍,不需要等 待机动画 播放完毕,所以我们选择不勾选。
- Transition Duration(s):过度动画持续时间,一般3D动画的话会用上来实现动画状态之间混合。在这里我们是使用基于Sprite的动画,所以不需要过度,设置为0即可。
- Conditions:进行转换的触发条件,在这里我们选择playerChop。
现在需要配置从PlayerChop到PlayerIdle的转换。选中高亮从PlayerChop到PlayerIdle的白线:
- Has Exit Time:和前面相反,我们在从劈砍动画转换回待机动画的时候,必须保证劈砍动画播放完毕了才可以转为待机动画。所以这里我们需要勾中此项。
- Exit Time:当Has Exit Time为选中状态的时候,此项可进行配置。在此例中我们修改为1,这样确保在进行转换之前劈砍动作是执行完毕了的。
- Transition Duration(s):同上,为0即可。
- Conditions:因为我们勾选了 Has Exit Time选项,也就意味着在Exit Time结束,劈砍动画播放完毕之后会自动转换为下一个状态,因此不需要额外设定触发条件。
如此就完成了PlayerIdle和PlayerChop之间的互相转换的设置。用同样的步骤,我们连接PlayerIdle和PlayerHit两种状态并且进行设置,当然,从待机到受伤的Conditions是选择playerHit条件的。
OK,我们可以来测试一下动画转换是否成功。
左键点击Animator面板标签栏不松开,拖动到下面Console右侧,然后运行游戏,分别选中playerChop和playerHit触发器,注意查看大胡子的动作。
可以看到大胡子在选择对应触发器的情况下做出了正确的反应,配置正确!
二、劈砍障碍墙
前面提过,遇到障碍墙的时候是在OnCantMove()内执行敲墙相关逻辑。那我们就在Player脚本里增加以下代码:
- 首先是新增公共成员wallDamage,指的是每次劈砍对障碍墙造成的伤害值,在这里我们设置默认值1,可以在编辑器里的inspector窗口修改。
- 新增一个私有成员animator,挂载在Player物体上的Animator组件,并且在Start()方法内进行初始化赋值。因为对Start()进行了重写,所以需要添加修饰关键词override,然后通过base.Start()调用了父类MovingObject的Start()方法。
- 在OnCantMove()方法内,把传入来的组件参数转化为我们想要的Wall类型,赋值给hitWall,然后调用Wall类的DamagWall方法来实现真正的攻击障碍墙操作(如替换砖图片、扣障碍墙生命等)。为了让我们肉眼识别出角色在进行攻击,我们需要调用animator的SetTrigger()方法来激活playerChop触发器,这样就会播放对应的chop动画,看到大胡子在劈砍障碍墙。
可以注意到,大胡子的确是成功把障碍墙砍倒了,但是我只按了一次向右方向键,它却执行了4次敲墙操作让墙消失了(Wall类设置墙生命是4)。回顾之前我们为了解决单次输入却多次移动而临时添加的修正代码,其实也很好理解,是由于基类里AttempMove()方法内最后一行,我们让开关为true了,执行了多次Update()。后续写怪物逻辑的时候会同步修正这个问题。
三、拾取食物和饮料
拾取食物和饮料是为了补充角色的生命(或者称呼为血量、食物、体力、点数都可以),这个生命有以下逻辑:
- 走一步扣1点生命(按一次移动方向键就扣1点,不管实际上是否成功移动)。
- 被攻击,减少定量生命。
- 拾取食物和饮料,增加定量生命。
- 小于等于0时则游戏结束。
那么,我们首先需要有生命这个成员属性,才能在编写拾取食物逻辑时调用。
第1步:增加角色生命属性
角色的生命属性贯穿于整个游戏过程,包括进入新的关卡也会把这个属性继承过去,因此理应是在GameController脚本中声明设定。
在GameController脚本中增加图中所示代码,新增一个公共成员playerFoodPoints,代表角色的生命值,初始值是100。
然后切换到Player脚本,增加如下代码。
- 新增私有成员变量food,作为角色的生命值。
- Start()方法,获取游戏管理器里角色生命值playerFoodPoints,并且赋值给food。
- 当物体被销毁时会自动调用OnDisable()方法,把当前角色生命值返回给游戏管理器。
第2步:拾取食物和饮料
前面制作素材的预制件的时候曾提过,把Food、Soda等的碰撞器Is Trigger选项选中,是为了可以在脚本代码里使用OnTriggerEnter2D等方法执行检测到碰撞之后的操作。那我们就把这个方法加入到Player脚本。
代码简析:
- 新增两个公共成员变量pointsPerFood、pointsPerSoda,分别指的是拾取食物和饮料之后能给角色增加多少生命值,并且赋予了初始值。
-
OnTriggerEnter2D方法,检测物理发生碰撞的时候调用执行里面的代码,参数other变量表示Player碰到的其他2D碰撞器,用if语句判断碰到的这个对象是否被标记了Tag为“Food”或者“Soda”,如果是的话则把对应的成员变量pointsPerFood或pointsPerSoda加给food,并且调用SetActive把这个对象设置为未激活状态(不显示),也就是false。
通过上述代码,我们可以实现在角色移动到食物或者饮料格子的时候,角色生命增加,食物或者饮料消失。
让我们来测试下。
很顺利,但是我们不知道生命是否真的增加了,那么可以试试增加Debug.Log代码来打印信息。
使用Debug.Log打印出来的信息会出现在Unity的Console控制台,测试程序的时候会经常利用这个方法来定位问题所在。
在OnTriggerEnter2D()方法内增加代码,把food值打印出来。
再运行游戏,并且切换到Console窗口。
可以看到,在吃了一堆红果子之后,food值打印出来是110,原来的100加上新增10点生命,数目是正确的!后面的苏打水同理。
测试完毕,记得把这两句debug代码删掉啦~
四、被敌人攻击
在Player脚本里增加一个LoseFood()方法,代码如下:
- 方法为public是因为攻击的主体是Enemy,所以会在Enemy的脚本里调用这个方法来实现对角色的扣除生命操作。
- 传入的参数loss是指敌人单次攻击对角色造成的伤害数值,当角色被攻击的时候,会播放hit动画,生命会被扣除,同时检测如果生命小于等于0,则角色死亡,游戏结束。
游戏管理者控制游戏进程,因此我们把游戏结束逻辑代码写在了GameController脚本里并在LoseFood()里调用。
五、移动到Exit进入下一关
角色移动到关卡右上角的Exit格子的时候,是进入下一关卡,也就是说重新加载场景生成下一关卡的地图。
- 我们需要在头部引入UnityEngine.SceneManagement,这样后面代码才可以调用方法SceneManager.LoadScene()。
- 在OnTriggerEnter2D里新增判断,当tag为Exit的时候,就通过Invoke方法来延时调用Restart方法执行加载场景操作。
Invoke()可以实现根据时间调用指定的方法,延时加载场景,这样视觉上过渡更加平滑,不突兀。
- Restart(),调用SceneManager.LoadScene()方法来重新加载指定的场景,0代表最后加载的场景,在我们这个例子里就是唯一的场景Main。
enabled = false 可以禁用脚本,避免角色在加载新场景之前就再次移动了。
这时候我们有个疑问:为什么重新加载了没有生成新的关卡?(无视粉红色,那是我个人配置的背景色...)
真相只有一个,“罪魁祸首”就是它:DontDestroyOnLoad()!
GameController作为游戏管理器,从头到尾只能有一个并且直到游戏结束才需要销毁,所以在之前讲单例模式的时候调用了DontDestroyOnLoad()方法来保证重新加载场景的时候不销毁实例。
那不销毁的情况下为什么会导致不生成新关卡呢?这和Awake()的特点有关系:
在游戏对象被销毁之前的整个生命周期之中,Awake()只执行一次!
所以重新加载场景之后,Awake()不执行,自然就不能调用InitGame()方法来生成随机关卡了。
我们试试把代码注释掉再运行游戏。
的确是有效了,但是这样做会导致游戏管理器每次进入新关卡就被摧毁,需要保留继承的数据比如人物生命、回合开关等都会被还原为初始值。因此,我们是建议使用另外更好的办法来实现重新加载场景时生成下一关的,后续会补充。
测试完毕,记得把两条杠去掉啦~
六、本篇收尾
我们讲过,角色意图移动的时候就会扣1点生命,现查漏补缺,把相关逻辑代码加上:
- 在Player的Update()里,是每次获取了输入就执行AttempMove(),因此我们把相关的扣血逻辑写在AttempMove()方法内。
- 首先就是扣除生命1点,然后通过base调用父类的方法进行判断和移动,最后如果生命小于低于0则调用GameOver()方法结束游戏。
判断游戏是否结束这段代码之前也在LoseFood()方法内用过,所以我们可以把这段代码提炼出来作为一个新的方法,这样以后再有需要的地方就可以直接调用,实现代码复用。
感兴趣的童鞋还可以用Debug打印下food值,看是不是每走一步扣1点生命。
角色移动的逻辑基本都实现了,完成了这个小游戏最核心的机制,接下来就是一马平川啦!