当前位置: 首页 > news >正文

Godot游戏练习01-第17节-状态机管理的敌人

Godot游戏练习01-第17节-状态机管理的敌人

在本节中, 我们使用状态机来给敌人添加多个状态, 实现一个更复杂更智能的敌人

看看效果

现在我们的Enemy具有上一节提到的4种状态:

  • spawn: 生成时不可移动, 播放生成动画
  • normal: 正常跟踪最近玩家, 但没有伤害, 与其他敌人正常碰撞
  • charge: 减速并显示蓄力提示, 准备攻击
  • attack: 加速向玩家冲刺, 并取消与其他敌人的碰撞
  • die: 从场景移除

anim1

实现思路

基于上一节实现的StateMachine和State基类

状态机源码修改

上次给出的源码有一点问题, 主要是状态修改current_state的改动与_state_transition函数的调用会无限递归 -- 修改current_state会导致_state_transition函数调用, 而原版的_state_transition函数中又会直接修改 current_state

因此这里用另外一个临时变量_current_state避免了loop, 在函数内部需要修改当前状态时, 直接修改_current_state, 避免递归

并且这里_state_transition的状态切换使用call_deferred延迟到帧结束后调用, 避免同一帧的逻辑中出现两个不同的状态

var _current_state: String = String()
var current_state: String:get:return _current_stateset(value):if value != _current_state:_state_transition.call_deferred(value)

敌人的状态机

2

在Enemy场景中直接添加新节点, 搜索StateMachine, 因为这是一个显式赋予了类名的类, 并且继承于Node, 因此可以直接找到这个类并实例化节点

之后在该节点下创建5个普通Node节点(截图少一个Died状态) , 并分别命名为Spawn, Normal, Charge, Attack, Died表示敌人的5种状态

给每个状态节点都挂载一个脚本, 脚本都继承自基础的State类, 可以在脚本中覆盖基类的enter, update, exit生命周期方法

脚本改动与具体实现

引入状态机之后, 敌人内部的逻辑仅保留一些逻辑方法, 动画/显示方法, 以及状态机初始状态的设置

这里添加了一个攻击警告⚠️图标, 一个攻击冷却的AttackCoolDownTimer, 一个充能计时的ChargeTimer, 不再赘述添加过程

class_name Enemy
extends CharacterBody2D@onready var track_timer: Timer = $TrackTimer
@onready var health_component: HealthComponent = $HealthComponent
@onready var visual: Node2D = $Visual
@onready var state_machine: StateMachine = $StateMachine
@onready var warning_icon: Sprite2D = $WarningIcon
@onready var attack_cool_down_timer: Timer = $AttackCoolDownTimer
@onready var charge_timer: Timer = $ChargeTimer
@onready var hit_collision_shape_2d: CollisionShape2D = %HitCollisionShape2Dvar track_target: Vector2
var has_track_target: bool = false
var charge_tip_tween: Tweenfunc _ready() -> void:warning_icon.scale = Vector2.ZEROif is_multiplayer_authority():track_timer.timeout.connect(_on_track_timer_timeout)health_component.health_depleted.connect(_on_health_depleted)state_machine.current_state = "spawn"else:track_timer.process_mode = Node.PROCESS_MODE_DISABLEDfunc _process(_delta: float) -> void:if is_multiplayer_authority():move_and_slide()## 播放生成动画
func play_spawn_animation() -> void:var tween = create_tween()tween.tween_property(visual, "scale", Vector2.ONE, 0.4)\.from(Vector2.ZERO)\.set_ease(Tween.EASE_OUT)\.set_trans(Tween.TRANS_BACK)if is_multiplayer_authority():# 状态切换仅在服务端执行tween.finished.connect(func():state_machine.current_state = "normal")func show_charge_tip() -> void:charge_tip_tween = create_tween()charge_tip_tween.tween_property(warning_icon, "scale", Vector2.ONE, 0.2)\.from(Vector2.ZERO)\.set_ease(Tween.EASE_OUT)\.set_trans(Tween.TRANS_BACK)func hide_charge_tip() -> void:if charge_tip_tween.is_valid():charge_tip_tween.kill()charge_tip_tween = create_tween()charge_tip_tween.tween_property(warning_icon, "scale", Vector2.ZERO, 0.2)\.from(Vector2.ONE)\.set_ease(Tween.EASE_OUT)\.set_trans(Tween.TRANS_BACK)func velocity_down() -> void:velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-10 * get_process_delta_time()))func update_direction() -> void:visual.scale = Vector2.ONE\if track_target.x > global_position.x\else Vector2(-1, 1)func update_track_target() -> void:var players := get_tree().get_nodes_in_group("player")var min_squared_distance: floatvar track_player: Node2D = nullfor player in players:if track_player == null:track_player = playermin_squared_distance = track_player.global_position.distance_squared_to(global_position)var squared_distance = player.global_position.distance_squared_to(global_position)if squared_distance < min_squared_distance:min_squared_distance = squared_distancetrack_player = playerif track_player != null:track_target = track_player.global_positionhas_track_target = trueelse:has_track_target = falsefunc _on_track_timer_timeout() -> void:update_track_target()func _on_health_depleted() -> void:state_machine.current_state = "died"

可以看到敌人内部的方法, 除了事件监听, 其余的就是动画和警告提示, 还有简单的减速操作, 更新朝向, 更新跟踪目标

具有5种状态切换的敌人, 本身的实现却相当简单, 100行搞定

复杂状态的处理被分配在每一个State中, 但是单个的State又不会很复杂

但是写每一行代码时都应该注意: 这是仅属于服务器的物理/移动/判断逻辑, 还是需要在所有对等端上同步显示的动画/显示逻辑?

生成状态 state_spawn

进入该状态时, 在所有peer播放动画, 在服务端速度设置为0

extends State## Enemy的生成状态,播放生成动画,不进行追踪和攻击var enemy: Enemyfunc enter() -> void:enemy = ownerenemy.play_spawn_animation()if is_multiplayer_authority():enemy.velocity = Vector2.ZERO

正常状态 state_normal

进入该状态, 开始追踪最近的目标(Player), 并且允许移动, 并且开始更新朝向

注释也比较清晰了, 哪一段逻辑仅authority执行, 哪一段逻辑需要所有人同步执行, 在多人游戏中必须弄清楚

extends State## Enemy的正常状态,允许移动const MAX_ATTACK_DISTANCE_SQUARED: float = 10000var enemy: Enemyfunc enter() -> void:enemy = ownerif is_multiplayer_authority():enemy.update_track_target()func update() -> void:# 服务器逻辑if is_multiplayer_authority():# 如果有跟踪目标,则设置速度,向目标移动if enemy.has_track_target:enemy.velocity = enemy.global_position.direction_to(enemy.track_target) * 40# 如果距离目标玩家距离小于一定值,准备攻击if enemy.global_position.distance_squared_to(enemy.track_target) < MAX_ATTACK_DISTANCE_SQUARED:if enemy.attack_cool_down_timer.is_stopped():enemy.attack_cool_down_timer.start()transitioned.emit("charge")# 显示/动画 -- 所有peerenemy.update_direction()

充能状态 state_charge

警告图标显示/隐藏, 减速, 并且在充能计时结束切换到攻击状态

extends State## Enemy进入充能攻击状态var enemy: Enemyfunc enter() -> void:enemy = owner# 显示/动画在所有peer展示enemy.show_charge_tip()# 逻辑相关仅服务器执行if is_multiplayer_authority():enemy.charge_timer.start()func update() -> void:# 物理/逻辑相关仅服务器执行if is_multiplayer_authority():enemy.velocity_down()if enemy.charge_timer.is_stopped():transitioned.emit("attack")func exit() -> void:enemy.hide_charge_tip()

攻击状态 state_attack

这里稍微复杂一点, 为了攻击时不与其他Enemy相互碰撞, 在该状态下临时禁用了Enemy之间的碰撞, 并在状态结束时恢复原碰撞层

在该状态下也临时允许对玩家造成伤害, 通过修改hit_collision_shape_2d的disabled属性实现, 这是HitBoxComponent组件下的碰撞形状

extends State
## Enemy进入攻击状态,冲撞玩家,可以造成伤害,攻击过程中不与其他Enemy碰撞const ATTACK_SPEED: float = 1000
const STOP_ATTACK_SPEED_SQUARED: float = 100var enemy: Enemy
var origin_collision_layer: int
var origin_collision_mask: intfunc enter() -> void:enemy = ownerif is_multiplayer_authority():origin_collision_layer = enemy.collision_layerorigin_collision_mask = enemy.collision_maskenemy.collision_layer = 0# 仅检测墙体碰撞enemy.collision_mask = (1 << 0)# 允许伤害玩家enemy.hit_collision_shape_2d.disabled = false# 初始攻击速度enemy.velocity = enemy.global_position.direction_to(enemy.track_target) * ATTACK_SPEEDfunc update() -> void:if is_multiplayer_authority():enemy.velocity_down()if enemy.velocity.length_squared() < STOP_ATTACK_SPEED_SQUARED:transitioned.emit("normal")func exit() -> void:if is_multiplayer_authority():enemy.hit_collision_shape_2d.disabled = trueenemy.collision_layer = origin_collision_layerenemy.collision_mask = origin_collision_mask

死亡状态 died_state

进入该状态时, 按照原有逻辑发送信号, 释放节点即可

extends State## Enemy的死亡状态var enemy: Enemyfunc enter() -> void:enemy = ownerGameEvents.emit_enemy_died()enemy.queue_free()

状态机的多人同步

这部分比想象的更简单, 在Enemy场景中的MultiplayerSynchronizer中新增StateMachine:current_state的属性状态同步即可, 设置为On Change, 该属性同步会自动触发setter, 触发状态切换函数调用, 而状态内部的逻辑我们已经做了authority判断, 一切都可以正常运行

总结

使用状态机之后, 敌人变得复杂智能了, 但源码的逻辑却很好的解耦到每一个状态中, 各状态只需要处理好自身状态下的逻辑, 把握好切换条件即可

再次强调, 多人游戏中需时刻注意服务器与客户端的逻辑拆分