欢迎阅读Godot3平台跳跃游戏实践系列文章,本系列将从创建工程开始,记录一个平台跳跃小游戏的制作过程,文章中如有错误或不妥之处欢迎指出。
上一篇文章中,我们调教了摄像姬,完善了地图,现在游戏世界还比较单调,本篇文章将围绕创建敌方角色展开,完成后效果:
本篇涉及以下内容:
- 史莱姆的制作
- 碰撞图层
- Timer定时器的使用
- Signal信号
- RayCast2D射线碰撞检测
- 角色间的互动
- Tween补间动画的使用
- 使用Area2D制作HitBox
- 使用AnimationPlayer制作动画
- 协程yield的使用
创建敌方角色
现在游戏世界里唯一的生物就是游戏主角,可以说是相当无聊了,我们来加入敌方角色。
文件管理器中搜索素材文件夹下带"enem"的子文件夹,可以发现素材作者提供了多种敌方角色,有史莱姆、小蜜蜂、蜘蛛等等:
挑几只你喜欢的来制作,或者"我全都要.gif",按照一贯传统,这里先来制作史莱姆。
碰撞检测层
假设现在要制作的这只史莱姆没什么追求,只想过平静的生活,不会飞也不会跳不会吐酸液更不会变形,终日就在平台上悠闲地来回爬动晒晒太阳这样子。不过在制作之前,我们需要给游戏中的碰撞分一下层。
之前我们的玩家和地面与平台都是在同一个碰撞层里,似乎也没什么问题,角色依然可以活碰乱跳。但随着不同类型的碰撞体加入,事情变得复杂起来,角色与敌人、敌人与敌人,包括之后可能会加入的收集物(比如金币)之间将可能产生各种乱七八糟的碰撞,敌人之间会互相推搡、收集物被撞离原来的位置等等。
你大概不会希望金币被一只史莱姆吃掉(这还蛮好玩的),所以我们来将各种碰撞体分配到不同的图层,通过定义不同图层之间是否允许碰撞,来避免上述情形发生。
首先来给要用到的图层命名,点击菜单Project -> Project Settings
打开项目设置,左边拉到最底,找到Layer Names
下的2d physics
,在右侧可以发现Godot提供了20个碰撞图层,将要用到的图层改个名:
目前我用到了四个图层:平台platforms、玩家player、敌人enemies,以及之后可能要做的收集物pickups.
就算不命名碰撞图层也可以使用它们,这和不把电影票拍照发朋友圈也可以看电影差不多。
史莱姆场景
史莱姆场景的组成与玩家角色有些类似,同样需要运动、动画与碰撞,不同的是史莱姆的运动由脚本控制,而不是玩家输入控制。
创建一个史莱姆场景,相信看过(三)-创建游戏的主角这篇之后应该轻车熟路了。史莱姆与玩家角色差别不大,创建KinematicBody2D
作为根节点,然后在其下创建AnimatedSprite
与CollisionShape2D
,然后保存场景:
给AnimatedSprite
节点制作动画,我用的是这个粉色的史莱姆,先创建了三个动画,分别是静止idle、行走walk、被压扁squashed,每个动画都只有一帧:
给CollisionShape2D
节点添加一个胶囊碰撞形状:
然后给根节点分配一下碰撞图层,选中根节点,Inspector面板中展开Collision
属性,Layer
表示节点属于哪几个图层,Mask
表示节点需要跟哪些图层产生碰撞:
史莱姆属于敌人,所以Layer
点亮第三个,它需要与平台和玩家发生碰撞,所以Mask
点亮前两个。
基本就创建好了,可以把它实例化到Level1场景中看看效果。
史莱姆脚本
我们需要让史莱姆动起来,这一只行动起来应该非常缓慢,像一只毛毛虫的感觉。那么我们可以给它设置一个较缓慢的速度,并且可以让它有规律地走走停停。
给根节点挂一个新脚本Slime.gd,史莱姆也会有当前速度、最大速度、所受重力等,有些成员变量可以从Player那里搬过来:
extends KinematicBody2D
export var gravity = 3800
export var max_speed = 30
var velocity = Vector2(0, 0)
onready var AnimatedSprite = $AnimatedSprite
最大速度调慢一些,我们不需要一只风驰电掣的史莱姆。之前的文章没有提到的,export
关键字可以将修饰的变量暴露给编辑器,这样可以直接在编辑器中调整这些值,而不用修改脚本:
接下来的脚本也跟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
:
如何知道定时器到点了呢?切换到这么久以来一直被冷落的Node
面板,可以发现在Signals
下有一系列类似于事件的东西:
Godot中使用观察者模式实现了节点之间消息的监听处理,叫做信号(Signals)。比如这里定时器到点了,它就会发出一个timeout
信号,订阅了此信号的节点便会在已连接的函数中收到这个信号。那么这里双击timeout
,在弹出的窗口中选择根节点,点击Connect即可:
可以看到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
函数似乎也没什么问题。
来运行看看效果(完整代码将在文章最后给出):
地形检测
看起来还不错,就是不太聪明的样子。上面的代码中只是临时设置了一个前进方向,史莱姆将一直朝一个方向前进,我们来让它学会掉头。
目前有两种情况需切换方向:走到平台边缘与碰到墙。有许多方法能判断这两种情况,这里我使用相对简单的射线碰撞检测,在根节点下创建四个RayCast2D
节点,分别用作两边的地板、两边的墙检测:
勾选Enabled
开启,调整Cast To
改变射线的长度方向,拖动调整位置,Collision Mask
因为需要检测的平台正好在第一个图层,所以不变。调整好后大概这个样子:
我们可以通过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的这一分支时,需要保持原来的方向继续前行(之前处于停止状态),原来的方向可以通过动画是否翻转得知。
运行,史莱姆不会跳崖了:
与玩家交互
史莱姆基本完成之后,来做它与玩家的交互。
来一jio
现在玩家踩在史莱姆上并不会发生什么,二者相安无事,因为我们还没有对玩家角色与史莱姆的碰撞做处理。打开Player场景,别忘了给Player的根节点指定一下碰撞图层,这跟现在要做的功能没多大关系,但之后会用到的:
依然是使用射线碰撞检测,因为角色有一定的宽度,一条射线可不够用。首先创建一个Node2D
节点当做文件夹使用,然后在其下创建三个RayCast2D
节点:
因为是要跟敌方角色做碰撞检测,所以RayCast2D
节点的Collision Mask
属性点亮第三个图层。然后调整射线长度与位置大概成这个样子:
我们需要在每一个物理帧检测这三条射线是否有碰撞,如果有碰撞并且碰撞物是史莱姆的话,那么史莱姆需要切换到压扁状态,同时角色需要向上弹起。
打开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
定义为可修改的成员变量,方便以后修改,我就偷懒直接写死了。
运行一下看看效果:
可以发现,史莱姆变成了蹦床,我们还没有对踩扁状态做任何处理。
那么来让它变扁,假设这只史莱姆被踩扁后就无法再抢救了,接下来就是让它慢慢消失,消失过程中不能被角色二次踩踏。
制作缓慢消失效果需要使用到补间动画,首先在Slime场景中再添加一个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_bit
与set_collision_mask_bit
函数分别控制史莱姆的所在碰撞图层及与哪些图层碰撞,第一个参数bit即为图层的位数,第二个参数true开启false关闭,这里表示将其从"enemies"图层移除、不与"player"图层碰撞。简单来说,这两行执行完后史莱姆就不会再被角色踩到了。
然后是长得要命的Tween调用,缓慢消失是一个淡出效果,改变图片透明度即可。这里表示改变AnimatedSprite
变量的modulate
属性,初始值为AnimatedSprite.modulate
即现在的值,最终值为Color(1, 1, 1, 0)
即完全透明的白色、持续时间1
秒、插值类型为线性
、以及对线性没什么用的但对其他插值有用的必须要传的一个参数。
消失动画结束后,需要让史莱姆真正的消失,即从场景中移除。我们需要知道Tween何时执行完了,与Timer
一样,Tween
也有对应的信号可以订阅。选中Tween
节点,在Node面板中双击tween_completed
信号,同样连接到根节点上:
Slime.gd生成的函数中:
func _on_Tween_tween_completed(object, key):
if object == AnimatedSprite and key == ":modulate":
queue_free()
queue_free()
将会在当前帧结束时将当前节点及其所有子节点全部删除。这里的判断有些多余,不写也可以,但如果Tween还处理其他节点或属性的话就能派上用场了。
运行效果:
反击
史莱姆只能被踩也太可怜了,要让它能对角色产生威胁,先来做最基础的接触伤害。
伤害区域
首先在史莱姆身上创建一个伤害区域,当角色进入到这个区域时,让角色受到伤害。打开Slime场景,在根节点下创建一个Area2D
节点,取名为"HitBox",并在其下创建一个CollisionShape2D
节点:
调整Area2D
节点的Collision
属性,让它能检测到玩家的碰撞:
然后给CollisionShape2D
节点添加一个胶囊形状,比碰撞形状要大一些:
伤害区域就制作完成了,接下来需要知道玩家角色何时进入到了这个区域。依然是使用信号,选中"HitBox",在Node面板中可以看到Area2D
节点会发出的信号:
可以看到有区域与区域之间的碰撞、区域与物体的碰撞,这里选择body_entered
,同样将信号连接到根节点上,Slime.gd中将生成一个_on_HitBox_body_entered
函数,稍后来补充这个函数。
玩家受击
通常玩家角色受到伤害后会有一些动画效果,比较简单的效果便是闪烁了;如果是碰到史莱姆的话,可能还会被向后弹一段距离。
受击动画
先来制作受击时的闪烁动画,闪烁的实现方式有很多,正好还没用过AnimationPlayer
节点,这里使用它来实现闪烁。
AnimationPlayer
通过将各节点的属性记录为一个个关键帧,动画播放时在关键帧之间进行属性变换,以此实现动画效果。例如这里要实现闪烁效果,那么将会有两个关键帧,第一个关键帧角色不透明,第二个关键帧角色为完全透明,循环播放后便是闪烁效果了。
在Player场景下创建一个AnimationPlayer
节点:
我们需要根据AnimatedSprite
节点的透明度来创建关键帧,选中它,在工作区底部点击Animation
面板,点击上方Animation
按钮创建一个新的动画,取名为"hurt":
可以发现Inspector面板中的属性右侧多了一把钥匙,这就是插入关键帧按钮,点击modulate
属性右侧的钥匙插入第一个不透明的关键帧:
关键帧插入后自动创建了一条属于modulate
属性的轨道,右侧可以调整动画的时间,轨道中可以拖动当前帧位置:
将时间调整为你喜欢的数值,把当前帧拖到最后,然后修改modulate
属性为透明,再次点击钥匙插入一个关键帧:
轨道里又多了一个关键帧,点击右侧的循环按钮开启循环,点击播放按钮可以看看效果,闪烁动画就制作好了:
受伤这么严肃的事情还要一直笑吗?切换到SpriteFrames
面板给角色添加一个受击的动画:
受击处理
又到了代码时间,我们已经能接收到角色进入史莱姆的伤害区域信号了,接下来需要让角色受到伤害,以及向后弹。在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