[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色

欢迎阅读Godot3平台跳跃游戏实践系列文章,本系列将从创建工程开始,记录一个平台跳跃小游戏的制作过程,文章中如有错误或不妥之处欢迎指出。

演示效果

上一篇文章中,我们调教了摄像姬,完善了地图,现在游戏世界还比较单调,本篇文章将围绕创建敌方角色展开,完成后效果:

最终效果

本篇涉及以下内容:

  • 史莱姆的制作
  • 碰撞图层
  • Timer定时器的使用
  • Signal信号
  • RayCast2D射线碰撞检测
  • 角色间的互动
  • Tween补间动画的使用
  • 使用Area2D制作HitBox
  • 使用AnimationPlayer制作动画
  • 协程yield的使用

创建敌方角色

现在游戏世界里唯一的生物就是游戏主角,可以说是相当无聊了,我们来加入敌方角色。

文件管理器中搜索素材文件夹下带"enem"的子文件夹,可以发现素材作者提供了多种敌方角色,有史莱姆、小蜜蜂、蜘蛛等等:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第1张图片
敌方角色素材

挑几只你喜欢的来制作,或者"我全都要.gif",按照一贯传统,这里先来制作史莱姆。

碰撞检测层

假设现在要制作的这只史莱姆没什么追求,只想过平静的生活,不会飞也不会跳不会吐酸液更不会变形,终日就在平台上悠闲地来回爬动晒晒太阳这样子。不过在制作之前,我们需要给游戏中的碰撞分一下层。

之前我们的玩家和地面与平台都是在同一个碰撞层里,似乎也没什么问题,角色依然可以活碰乱跳。但随着不同类型的碰撞体加入,事情变得复杂起来,角色与敌人、敌人与敌人,包括之后可能会加入的收集物(比如金币)之间将可能产生各种乱七八糟的碰撞,敌人之间会互相推搡、收集物被撞离原来的位置等等。

你大概不会希望金币被一只史莱姆吃掉(这还蛮好玩的),所以我们来将各种碰撞体分配到不同的图层,通过定义不同图层之间是否允许碰撞,来避免上述情形发生。

首先来给要用到的图层命名,点击菜单Project -> Project Settings打开项目设置,左边拉到最底,找到Layer Names下的2d physics,在右侧可以发现Godot提供了20个碰撞图层,将要用到的图层改个名:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第2张图片
碰撞图层设置

目前我用到了四个图层:平台platforms、玩家player、敌人enemies,以及之后可能要做的收集物pickups.

就算不命名碰撞图层也可以使用它们,这和不把电影票拍照发朋友圈也可以看电影差不多。

史莱姆场景

史莱姆场景的组成与玩家角色有些类似,同样需要运动、动画与碰撞,不同的是史莱姆的运动由脚本控制,而不是玩家输入控制。

创建一个史莱姆场景,相信看过(三)-创建游戏的主角这篇之后应该轻车熟路了。史莱姆与玩家角色差别不大,创建KinematicBody2D作为根节点,然后在其下创建AnimatedSpriteCollisionShape2D,然后保存场景:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第3张图片
创建史莱姆场景

AnimatedSprite节点制作动画,我用的是这个粉色的史莱姆,先创建了三个动画,分别是静止idle、行走walk、被压扁squashed,每个动画都只有一帧:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第4张图片
创建史莱姆动画

CollisionShape2D节点添加一个胶囊碰撞形状:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第5张图片
创建史莱姆碰撞形状

然后给根节点分配一下碰撞图层,选中根节点,Inspector面板中展开Collision属性,Layer表示节点属于哪几个图层,Mask表示节点需要跟哪些图层产生碰撞:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第6张图片
分配碰撞图层

史莱姆属于敌人,所以Layer点亮第三个,它需要与平台和玩家发生碰撞,所以Mask点亮前两个。

基本就创建好了,可以把它实例化到Level1场景中看看效果。

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第7张图片
实例化史莱姆到场景

史莱姆脚本

我们需要让史莱姆动起来,这一只行动起来应该非常缓慢,像一只毛毛虫的感觉。那么我们可以给它设置一个较缓慢的速度,并且可以让它有规律地走走停停。

给根节点挂一个新脚本Slime.gd,史莱姆也会有当前速度、最大速度、所受重力等,有些成员变量可以从Player那里搬过来:

extends KinematicBody2D

export var gravity = 3800
export var max_speed = 30
var velocity = Vector2(0, 0)

onready var AnimatedSprite = $AnimatedSprite

最大速度调慢一些,我们不需要一只风驰电掣的史莱姆。之前的文章没有提到的,export关键字可以将修饰的变量暴露给编辑器,这样可以直接在编辑器中调整这些值,而不用修改脚本:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第8张图片
暴露脚本变量

接下来的脚本也跟Player.gd中的差不多,只需要将由玩家输入控制方向,改成由脚本自身来控制方向即可,并且也没有了跳跃的逻辑,相当轻松,稍微改改就能上了。

但是我拒绝

这样写出来的只不过是一个走的慢一点的玩家角色而已。我们来做点有意思的,比如让史莱姆行走一段距离,然后停下,再继续行走、停下,不断重复这个过程。

分析这个过程,史莱姆将至少包含三种状态:静止、行走、还有可能会被角色一jio踩扁,没有被踩时,它将在静止与行走状态之间来回切换,这个切换可以用一个定时来做。

那么在脚本前面用枚举关键字enum定义以下这些状态,稍后将利用它们来进行状态切换,同时定义一个成员变量来记录当前状态:

enum State {IDLING, WALKING, SQUASHED}
var state = State.IDLING

物理帧的处理也比较简单,保持一个速度前进即可,通过一个成员变量direction来控制行走的方向,值为-1向左走、0停止、1向右走:

var direction = 0

func _physics_process(delta):
    # 计算x方向与y方向速度,并移动
    velocity.x = max_speed * direction
    velocity.y += gravity * delta
    velocity = move_and_slide(velocity)

状态切换与定时器

接下来要实现状态的切换,首先要定时的话需要用到定时器,在根节点下创建一个Timer类型的节点。因为运动需要在物理帧做处理,所以Process Mode选择Physics,并且不需要自动重复,勾选上One Shot

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第9张图片
创建Timer节点

如何知道定时器到点了呢?切换到这么久以来一直被冷落的Node面板,可以发现在Signals下有一系列类似于事件的东西:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第10张图片
Node面板-信号

Godot中使用观察者模式实现了节点之间消息的监听处理,叫做信号(Signals)。比如这里定时器到点了,它就会发出一个timeout信号,订阅了此信号的节点便会在已连接的函数中收到这个信号。那么这里双击timeout,在弹出的窗口中选择根节点,点击Connect即可:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第11张图片
信号连接

可以看到Slime.gd中多了一个函数,函数将会在定时器到点时调用:

信号连接函数

函数里要写什么呢?如果希望史莱姆不断在走-停之间切换的话,这里的逻辑就很简单了:当前状态为静止的话,就设置状态为行走,当前为行走的话,就设置为静止,以此不断地在两个状态间切换:

func _on_Timer_timeout():
    # 定时器到点,在两个状态间切换
    if state == State.IDLING:
        set_state(State.WALKING)
    elif state == State.WALKING:
        set_state(State.IDLING)

set_state函数还没写呢?别急,现在就来实现它:

onready var Timer = $Timer  # 别忘了获取到Timer节点

func set_state(new_state):
    state = new_state   # 设置当前状态
    # 根据不同的新状态作出处理
    match state:
        State.IDLING:
            direction = 0   # 静止
            AnimatedSprite.play("idle")
            Timer.start(0.5)  # 开启定时
        State.WALKING:
            direction = 1   # TODO 先临时设置一个方向
            AnimatedSprite.play("walk")
            AnimatedSprite.flip_h = direction > 0
            Timer.start(0.5)
        State.SQUASHED:
            # 暂时不处理
            pass

match关键字其实就是GDScript版本的switch,但它不需要写break。当设置状态时,调整对应的运动方向与动画,最后开启定时器随便定一个时间(单位秒),定时器时间到了之后便会触发上面的_on_Timer_timeout重新设置状态,形成一个无限循环。

然后在_ready函数中调用一下set_state函数,设置一个初始状态,并开启这个无限循环:

func _ready():
    call_deferred("set_state", State.IDLING)

保险起见使用了call_deferred延迟调用函数,用于在帧之间的空闲时间调用某个函数,需要注意的是这个调用可能会比物理帧_physics_process来得晚。当前这里直接调用set_state函数似乎也没什么问题。

来运行看看效果(完整代码将在文章最后给出):

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第12张图片
旁边的人怎么不去救它.gif

地形检测

看起来还不错,就是不太聪明的样子。上面的代码中只是临时设置了一个前进方向,史莱姆将一直朝一个方向前进,我们来让它学会掉头。

目前有两种情况需切换方向:走到平台边缘与碰到墙。有许多方法能判断这两种情况,这里我使用相对简单的射线碰撞检测,在根节点下创建四个RayCast2D节点,分别用作两边的地板、两边的墙检测:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第13张图片
创建射线检测

勾选Enabled开启,调整Cast To改变射线的长度方向,拖动调整位置,Collision Mask因为需要检测的平台正好在第一个图层,所以不变。调整好后大概这个样子:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第14张图片
调整好的射线

我们可以通过is_colliding函数得知射线是否正接触到平台,以此作出相应处理。例如右侧地板射线没有接触到平台,说明前方是悬崖;例如左侧墙射线接触到了平台,说明前方是墙。

将刚才的set_state函数里临时写的那一行替换为方向检测:

func set_state(new_state):
    ...
    match state:
        State.IDLING:
        ...
        State.WALKING:
            direction = check_direction()   # 检测当前应该走的方向
            AnimatedSprite.play("walk")
            AnimatedSprite.flip_h = direction > 0
            Timer.start(0.5)

然后编写check_direction函数:

func check_direction():
    if not GroundCheckLeft.is_colliding():
        return 1
    elif not GroundCheckRight.is_colliding():
        return -1
    elif WallCheckLeft.is_colliding():
        return 1
    elif WallCheckRight.is_colliding():
        return -1
    elif direction == 0:
        return 1 if AnimatedSprite.flip_h else -1
    else:
        return direction

这里比较简单就不一一讲解了,需要注意的是判断到direction为0的这一分支时,需要保持原来的方向继续前行(之前处于停止状态),原来的方向可以通过动画是否翻转得知。

运行,史莱姆不会跳崖了:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第15张图片
地形检测-效果
[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第16张图片
地形检测-效果2

与玩家交互

史莱姆基本完成之后,来做它与玩家的交互。

来一jio

现在玩家踩在史莱姆上并不会发生什么,二者相安无事,因为我们还没有对玩家角色与史莱姆的碰撞做处理。打开Player场景,别忘了给Player的根节点指定一下碰撞图层,这跟现在要做的功能没多大关系,但之后会用到的:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第17张图片
Player碰撞图层设置

依然是使用射线碰撞检测,因为角色有一定的宽度,一条射线可不够用。首先创建一个Node2D节点当做文件夹使用,然后在其下创建三个RayCast2D节点:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第18张图片
Player射线检测

因为是要跟敌方角色做碰撞检测,所以RayCast2D节点的Collision Mask属性点亮第三个图层。然后调整射线长度与位置大概成这个样子:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第19张图片
Player射线位置

我们需要在每一个物理帧检测这三条射线是否有碰撞,如果有碰撞并且碰撞物是史莱姆的话,那么史莱姆需要切换到压扁状态,同时角色需要向上弹起。

打开Player.gd脚本文件,首先加入射线的碰撞检测,检测的逻辑需要在_physics_process中调用:

onready var RayCasts = $RayCasts # 别忘了获取RayCasts节点

func _physics_process(delta):
    ...
    check_bounce()  # 检测脚底是否有弹跳物
    velocity = move_and_slide(velocity, FLOOR_NORMAL)

func check_bounce():
    # 检查脚底射线是否有碰撞
    for ray in RayCasts.get_children():
        if ray.is_colliding():
            var collider = ray.get_collider()
            ???

我们定义了一个check_bounce函数来检测角色脚底是否有弹跳物,并且在move_and_slide之前调用;check_bounce函数中遍历RayCasts节点下的三个RayCast2D节点,判断是否有碰撞,如有的话,我们就能拿到碰撞物collider了。

然后呢?有了碰撞物,还需要判断它是不是史莱姆,如果是的话,就调用史莱姆的某个函数让它进入压扁状态。

于是在Slime.gd脚本文件中,加入一个被踩函数trampled,参数trampler表示踩它的人:

func trampled(trampler):
    """被踩踏"""
    set_state(State.SQUASHED)

那么上面Player.gd中的???就很清楚了,执行collider的被踩函数trampled即可:

func check_bounce():
    # 检查脚底射线是否有碰撞
    for ray in RayCasts.get_children():
        if ray.is_colliding():
            var collider = ray.get_collider()
            if collider.has_method("trampled"):
                collider.trampled(self)
                break

归功于动态类型语言,我们只需判断collider是否有trampled方法存在,而不用判断collider到底是什么类型。

还需要让角色能弹起来,考虑到以后会有不同类型的敌人、或者弹簧,踩在上面跳起的高度也会有所不同,所以我们不直接在Player.gd里面调用弹起函数,而是交给被踩的那一位。首先在Player.gd中加入弹跳bounce函数:

func bounce(speed):
    velocity.y = speed

speed将由被踩者提供。然后继续补充Slime.gd

func trampled(trampler):
    """被踩踏"""
    set_state(State.SQUASHED)
    if trampler.has_method("bounce"):
        trampler.bounce(-600)

使用同样的伎俩,调用角色的bounce函数,传入弹跳高度,高度可以使用export var定义为可修改的成员变量,方便以后修改,我就偷懒直接写死了。

运行一下看看效果:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第20张图片
Q弹史莱姆

可以发现,史莱姆变成了蹦床,我们还没有对踩扁状态做任何处理。

那么来让它变扁,假设这只史莱姆被踩扁后就无法再抢救了,接下来就是让它慢慢消失,消失过程中不能被角色二次踩踏。

制作缓慢消失效果需要使用到补间动画,首先在Slime场景中再添加一个Tween节点:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第21张图片
新建Tween节点

它的属性没什么需要改的。然后补充Slime.gd中的set_state函数:

onready var Tween = $Tween # 别忘了获取Tween节点

func set_state(new_state):
        ...
        State.SQUASHED:
            direction = 0
            AnimatedSprite.play("squashed")
            # 避免被再次踩踏
            set_collision_layer_bit(2, false)
            set_collision_mask_bit(1, false)
            # 淡出消失动画
            Tween.interpolate_property(AnimatedSprite, "modulate", AnimatedSprite.modulate, Color(1, 1, 1, 0), 1, Tween.TRANS_LINEAR, Tween.EASE_IN)
            Tween.start()

set_collision_layer_bitset_collision_mask_bit函数分别控制史莱姆的所在碰撞图层及与哪些图层碰撞,第一个参数bit即为图层的位数,第二个参数true开启false关闭,这里表示将其从"enemies"图层移除、不与"player"图层碰撞。简单来说,这两行执行完后史莱姆就不会再被角色踩到了。

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第22张图片
查看图层bit

然后是长得要命的Tween调用,缓慢消失是一个淡出效果,改变图片透明度即可。这里表示改变AnimatedSprite变量的modulate属性,初始值为AnimatedSprite.modulate即现在的值,最终值为Color(1, 1, 1, 0)即完全透明的白色、持续时间1秒、插值类型为线性、以及对线性没什么用的但对其他插值有用的必须要传的一个参数。

消失动画结束后,需要让史莱姆真正的消失,即从场景中移除。我们需要知道Tween何时执行完了,与Timer一样,Tween也有对应的信号可以订阅。选中Tween节点,在Node面板中双击tween_completed信号,同样连接到根节点上:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第23张图片
Tween信号

Slime.gd生成的函数中:

func _on_Tween_tween_completed(object, key):
    if object == AnimatedSprite and key == ":modulate":
        queue_free()

queue_free()将会在当前帧结束时将当前节点及其所有子节点全部删除。这里的判断有些多余,不写也可以,但如果Tween还处理其他节点或属性的话就能派上用场了。

运行效果:

awsl

反击

史莱姆只能被踩也太可怜了,要让它能对角色产生威胁,先来做最基础的接触伤害。

伤害区域

首先在史莱姆身上创建一个伤害区域,当角色进入到这个区域时,让角色受到伤害。打开Slime场景,在根节点下创建一个Area2D节点,取名为"HitBox",并在其下创建一个CollisionShape2D节点:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第24张图片
创建Area2D

调整Area2D节点的Collision属性,让它能检测到玩家的碰撞:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第25张图片
Area2D检测

然后给CollisionShape2D节点添加一个胶囊形状,比碰撞形状要大一些:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第26张图片
伤害区域

伤害区域就制作完成了,接下来需要知道玩家角色何时进入到了这个区域。依然是使用信号,选中"HitBox",在Node面板中可以看到Area2D节点会发出的信号:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第27张图片
Area2D信号

可以看到有区域与区域之间的碰撞、区域与物体的碰撞,这里选择body_entered,同样将信号连接到根节点上,Slime.gd中将生成一个_on_HitBox_body_entered函数,稍后来补充这个函数。

玩家受击

通常玩家角色受到伤害后会有一些动画效果,比较简单的效果便是闪烁了;如果是碰到史莱姆的话,可能还会被向后弹一段距离。

受击动画

先来制作受击时的闪烁动画,闪烁的实现方式有很多,正好还没用过AnimationPlayer节点,这里使用它来实现闪烁。

AnimationPlayer通过将各节点的属性记录为一个个关键帧,动画播放时在关键帧之间进行属性变换,以此实现动画效果。例如这里要实现闪烁效果,那么将会有两个关键帧,第一个关键帧角色不透明,第二个关键帧角色为完全透明,循环播放后便是闪烁效果了。

在Player场景下创建一个AnimationPlayer节点:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第28张图片
创建AnimationPlayer节点

我们需要根据AnimatedSprite节点的透明度来创建关键帧,选中它,在工作区底部点击Animation面板,点击上方Animation按钮创建一个新的动画,取名为"hurt":

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第29张图片
创建动画

可以发现Inspector面板中的属性右侧多了一把钥匙,这就是插入关键帧按钮,点击modulate属性右侧的钥匙插入第一个不透明的关键帧:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第30张图片
插入关键帧

关键帧插入后自动创建了一条属于modulate属性的轨道,右侧可以调整动画的时间,轨道中可以拖动当前帧位置:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第31张图片

将时间调整为你喜欢的数值,把当前帧拖到最后,然后修改modulate属性为透明,再次点击钥匙插入一个关键帧:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第32张图片
modulate透明

轨道里又多了一个关键帧,点击右侧的循环按钮开启循环,点击播放按钮可以看看效果,闪烁动画就制作好了:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第33张图片
完成的动画轨道

受伤这么严肃的事情还要一直笑吗?切换到SpriteFrames面板给角色添加一个受击的动画:

[Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色_第34张图片
受伤动画

受击处理

又到了代码时间,我们已经能接收到角色进入史莱姆的伤害区域信号了,接下来需要让角色受到伤害,以及向后弹。在Player.gd中加入受击函数:

var is_hurt = false # 定义一个成员变量来记录受伤状态
onready var AnimationPlayer = $AnimationPlayer # 拿到AnimationPlayer节点

func take_damage(damage, bounce_force = Vector2.ZERO):
    """受到伤害,damage为伤害数值,bounce_force为受到的作用力"""
    is_hurt = true
    velocity += bounce_force
    # 播放闪烁动画
    AnimationPlayer.play("hurt")
    yield(get_tree().create_timer(0.6), "timeout")
    # 停止闪烁动画
    AnimationPlayer.seek(0, true)
    AnimationPlayer.stop()
    is_hurt = false

伤害与作用力都作为参数,将由伤害者提供,由于还没有加入生命值,伤害就先不处理了,后续要加上也很简单;作用力的处理比较粗暴,直接加在速度上就完事了。

接着播放刚才制作好的闪烁动画,由于是循环动画,一播放就停不下来,除非调用stop函数。而我们希望动画播放一段时间再停,这里便用到了协程yield

yield的第一个参数表示发出信号的对象,第二个参数表示需要接收的信号。函数执行到yield这一行时,将会暂停,直到收到了指定的信号后,才会继续往下执行。

这里我没有再创建一个Timer节点,而是直接通过节点树的create_timer创建了一个临时的定时器,0.6秒之后,函数继续往下执行,让动画回到不透明的那一帧,然后停止动画。

受击的这段短暂时间最好是不能操控角色的,一个一脸痛苦又还在乱跑的角色会有点奇怪。Player.gd中修改_physics_process函数,加入对是否处于受击状态is_hurt的判断:

func _physics_process(delta):
    # 水平方向运动
    var direction = 0
    if not is_hurt:
        # 获取水平方向输入
        direction = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
    # 使用插值函数计算速度
    velocity.x = lerp(velocity.x, direction * max_speed, 0.2)
    # 动画
    if is_hurt:
        AnimatedSprite.play("hurt") # 播放受击动画
    elif direction != 0:
        AnimatedSprite.flip_h = direction < 0 # 方向为负则翻转
        AnimatedSprite.play("walk") # 播放行走动画
    else:
        AnimatedSprite.play("idle") # 播放空闲动画
    
    # 竖直方向运动
    if not is_hurt:
        if is_on_floor():
            # 位于地面,获取跳跃输入
            if Input.is_action_just_pressed("ui_up"):
                velocity.y = jump_force
                # 随机范围音高
                AudioStreamPlayer.pitch_scale = rand_range(0.7, 1)
                AudioStreamPlayer.play() # 播放跳跃音效
        else:
            AnimatedSprite.play("jump") # 播放跳跃动画
    # 施加重力
    velocity.y += gravity * delta
    
    check_bounce()  # 检测脚底是否有弹跳物
    velocity = move_and_slide(velocity, FLOOR_NORMAL)

可以发现没有加多少处理,处于受击状态则水平方向与竖直方向不处理输入、播放受击动画即可。

角色的代码基本完成,然后是史莱姆的,就比较简单了,把_on_HitBox_body_entered函数完善,Slime.gd中:

func _on_HitBox_body_entered(body):
    if body.has_method("take_damage"):
        var bounce_force = body.global_position - self.global_position
        bounce_force = bounce_force.normalized() * 700
        body.take_damage(5, bounce_force)

调用进入伤害区域物体的take_damage函数即可,伤害数值随便写一个,反正现在也没用;这里作用力是一个由史莱姆位置指向玩家位置的向量,大小写一个喜欢的数值。

史莱姆被踩扁时禁用掉伤害区域:

func set_state(new_state):
        ...
        State.SQUASHED:
            ...
            # 禁用伤害区域
            $HitBox/CollisionShape2D.disabled = true
            # 淡出消失动画
            ...

运行效果:

运行效果

至此已经完成了一个敌方角色的创建、脚本控制以及与玩家角色的交互,制作完这个角色以后,相信其他敌方角色的制作也不是什么难事了。

在史莱姆的脚本编写中不难发现,做好状态管理可以让代码逻辑更加清晰易懂、更加容易维护与拓展,反观没有做状态管理的角色脚本,加入受击状态后的_physics_process函数已经有些乱了,如果之后还要加入更多的状态,比如滑铲、爬墙、射击等等,代码难免会变得更加凌乱不堪,下一篇文章将介绍如何在Godot中使用状态机管理角色状态。

[Godot3游戏引擎实践]平台跳跃小游戏(七)-使用状态机管理角色状态


本文完成后的完整脚本代码:

Slime.gd:

extends KinematicBody2D

enum State {IDLING, WALKING, SQUASHED}

export var gravity = 3800
export var max_speed = 30
var velocity = Vector2(0, 0)
var direction = 0
var state = State.IDLING

onready var AnimatedSprite = $AnimatedSprite
onready var Timer = $Timer
onready var Tween = $Tween
onready var GroundCheckLeft = $GroundCheckLeft
onready var GroundCheckRight = $GroundCheckRight
onready var WallCheckLeft = $WallCheckLeft
onready var WallCheckRight = $WallCheckRight

func _ready():
    call_deferred("set_state", State.IDLING)

func _physics_process(delta):
    # 计算x方向与y方向速度
    velocity.x = max_speed * direction
    velocity.y += gravity * delta
    velocity = move_and_slide(velocity)

func set_state(new_state):
    state = new_state   # 设置当前状态
    # 根据不同的新状态作出处理
    match state:
        State.IDLING:
            direction = 0   # 静止
            AnimatedSprite.play("idle")
            Timer.start(0.5)    # 开启定时
        State.WALKING:
            direction = check_direction()   # 检测当前应该走的方向
            AnimatedSprite.play("walk")
            AnimatedSprite.flip_h = direction > 0
            Timer.start(0.5)
        State.SQUASHED:
            direction = 0
            AnimatedSprite.play("squashed")
            # 避免被再次踩踏
            set_collision_layer_bit(2, false)
            set_collision_mask_bit(1, false)
            # 禁用伤害区域
            $HitBox/CollisionShape2D.disabled = true
            # 淡出消失动画
            Tween.interpolate_property(AnimatedSprite, "modulate", AnimatedSprite.modulate, Color(1, 1, 1, 0), 1, Tween.TRANS_LINEAR, Tween.EASE_IN)
            Tween.start()

func check_direction():
    """判断当前前进方向"""
    if not GroundCheckLeft.is_colliding():
        return 1
    elif not GroundCheckRight.is_colliding():
        return -1
    elif WallCheckLeft.is_colliding():
        return 1
    elif WallCheckRight.is_colliding():
        return -1
    elif direction == 0:
        return 1 if AnimatedSprite.flip_h else -1
    else:
        return direction

func trampled(trampler):
    """被踩踏"""
    set_state(State.SQUASHED)
    if trampler.has_method("bounce"):
        trampler.bounce(-600)

func _on_Timer_timeout():
    # 定时器到点,在两个状态间切换
    if state == State.IDLING:
        set_state(State.WALKING)
    elif state == State.WALKING:
        set_state(State.IDLING)

func _on_Tween_tween_completed(object, key):
    if object == AnimatedSprite and key == ":modulate":
        queue_free()

func _on_HitBox_body_entered(body):
    if body.has_method("take_damage"):
        var bounce_force = body.global_position - self.global_position
        bounce_force = bounce_force.normalized() * 700
        body.take_damage(5, bounce_force)

Player.gd:

extends KinematicBody2D

const FLOOR_NORMAL = Vector2.UP

var gravity = 3800
var jump_force = -1200
var max_speed = 400
var velocity = Vector2(0, 0)
var is_hurt = false

onready var AnimatedSprite = $AnimatedSprite
onready var AudioStreamPlayer = $AudioStreamPlayer
onready var AnimationPlayer = $AnimationPlayer
onready var RayCasts = $RayCasts

func _ready():
    set_camera_limits()

func set_camera_limits():
    """设置摄像机范围限制"""
    # 获取父节点中包含的TileMap节点
    var tile_map = $"../TileMap" as TileMap
    if tile_map == null:
        print_debug("没有找到关卡中的TileMap节点")
        return
    # 获取TileMap范围与瓦片大小
    var rect = tile_map.get_used_rect()
    var cell_size = tile_map.cell_size
    # 给相机设置范围限制
    var camera = $Camera2D
    camera.limit_left = 0
    camera.limit_top = 0
    camera.limit_right = rect.end.x * cell_size.x
    camera.limit_bottom = rect.end.y * cell_size.y

func _physics_process(delta):
    # 水平方向运动
    var direction = 0
    var lerp_weight = 0.2   # 速度插值权重
    if not is_hurt:
        # 获取水平方向输入
        direction = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
    else:
        lerp_weight = 0.09
    # 使用插值函数计算速度
    velocity.x = lerp(velocity.x, direction * max_speed, lerp_weight)
    # 动画
    if is_hurt:
        AnimatedSprite.play("hurt") # 播放受击动画
    elif direction != 0:
        AnimatedSprite.flip_h = direction < 0 # 方向为负则翻转
        AnimatedSprite.play("walk") # 播放行走动画
    else:
        AnimatedSprite.play("idle") # 播放空闲动画
    
    # 竖直方向运动
    if not is_hurt:
        if is_on_floor():
            # 位于地面,获取跳跃输入
            if Input.is_action_just_pressed("ui_up"):
                velocity.y = jump_force
                # 随机范围音高
                AudioStreamPlayer.pitch_scale = rand_range(0.7, 1)
                AudioStreamPlayer.play() # 播放跳跃音效
        else:
            AnimatedSprite.play("jump") # 播放跳跃动画
    # 施加重力
    velocity.y += gravity * delta
    
    check_bounce()  # 检测脚底是否有弹跳物
    velocity = move_and_slide(velocity, FLOOR_NORMAL)

func check_bounce():
    # 检查脚底射线是否有碰撞
    for ray in RayCasts.get_children():
        if ray.is_colliding():
            var collider = ray.get_collider()
            if collider.has_method("trampled"):
                collider.trampled(self)
                break

func bounce(speed):
    velocity.y = speed

func take_damage(damage, bounce_force = Vector2.ZERO):
    """受到伤害,damage为伤害数值,bounce_force为受到的作用力"""
    is_hurt = true
    velocity += bounce_force
    # 播放闪烁动画
    AnimationPlayer.play("hurt")
    yield(get_tree().create_timer(0.6), "timeout")
    # 停止闪烁动画
    AnimationPlayer.seek(0, true)
    AnimationPlayer.stop()
    is_hurt = false

你可能感兴趣的:([Godot3游戏引擎实践]平台跳跃小游戏(六)-创建敌方角色)