在本节中, 我们使用状态机来给敌人添加多个状态, 实现一个更复杂更智能的敌人
看看效果
现在我们的Enemy具有上一节提到的4种状态:
- spawn: 生成时不可移动, 播放生成动画
- normal: 正常跟踪最近玩家, 但没有伤害, 与其他敌人正常碰撞
- charge: 减速并显示蓄力提示, 准备攻击
- attack: 加速向玩家冲刺, 并取消与其他敌人的碰撞
- die: 从场景移除
实现思路
基于上一节实现的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_state
set(value):
if value != _current_state:
_state_transition.call_deferred(value)
敌人的状态机
在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 = %HitCollisionShape2D
var track_target: Vector2
var has_track_target: bool = false
var charge_tip_tween: Tween
func _ready() -> void:
warning_icon.scale = Vector2.ZERO
if 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_DISABLED
func _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: float
var track_player: Node2D = null
for player in players:
if track_player == null:
track_player = player
min_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_distance
track_player = player
if track_player != null:
track_target = track_player.global_position
has_track_target = true
else:
has_track_target = false
func _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: Enemy
func enter() -> void:
enemy = owner
enemy.play_spawn_animation()
if is_multiplayer_authority():
enemy.velocity = Vector2.ZERO
正常状态 state_normal
进入该状态, 开始追踪最近的目标(Player), 并且允许移动, 并且开始更新朝向
注释也比较清晰了, 哪一段逻辑仅authority执行, 哪一段逻辑需要所有人同步执行, 在多人游戏中必须弄清楚
extends State
## Enemy的正常状态,允许移动
const MAX_ATTACK_DISTANCE_SQUARED: float = 10000
var enemy: Enemy
func enter() -> void:
enemy = owner
if 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")
# 显示/动画 -- 所有peer
enemy.update_direction()
充能状态 state_charge
警告图标显示/隐藏, 减速, 并且在充能计时结束切换到攻击状态
extends State
## Enemy进入充能攻击状态
var enemy: Enemy
func 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 = 100
var enemy: Enemy
var origin_collision_layer: int
var origin_collision_mask: int
func enter() -> void:
enemy = owner
if is_multiplayer_authority():
origin_collision_layer = enemy.collision_layer
origin_collision_mask = enemy.collision_mask
enemy.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_SPEED
func 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 = true
enemy.collision_layer = origin_collision_layer
enemy.collision_mask = origin_collision_mask
死亡状态 died_state
进入该状态时, 按照原有逻辑发送信号, 释放节点即可
extends State
## Enemy的死亡状态
var enemy: Enemy
func enter() -> void:
enemy = owner
GameEvents.emit_enemy_died()
enemy.queue_free()
状态机的多人同步
这部分比想象的更简单, 在Enemy场景中的MultiplayerSynchronizer中新增StateMachine:current_state的属性状态同步即可, 设置为On Change, 该属性同步会自动触发setter, 触发状态切换函数调用, 而状态内部的逻辑我们已经做了authority判断, 一切都可以正常运行
总结
使用状态机之后, 敌人变得复杂智能了, 但源码的逻辑却很好的解耦到每一个状态中, 各状态只需要处理好自身状态下的逻辑, 把握好切换条件即可
再次强调, 多人游戏中需时刻注意服务器与客户端的逻辑拆分