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

0 阅读6分钟

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

看看效果

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

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

anim1.gif

实现思路

基于上一节实现的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)

敌人的状态机

2.png

在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判断, 一切都可以正常运行

总结

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

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