【简单的敌人AI】
现在考虑让敌人行动起来。敌人应该拥有以下基本能力:
- 寻找食物
- 避开障碍(包括墙和其他蛇)
【准备工作】
现在只考察一条敌方蛇的运动。首先需要准备场地,并且调整一些参数,方便观察。
-
在Level场景中点击TileMap结点。通过下方面板的Rect和橡皮擦工具清除当前的测试地图,然后绘制一个更大的地图。注意地面和墙在不同的层。尝试在地图中间也绘制一些墙,最后的效果大概是这样:
-
为Level场景添加两个Marker2D,拖动到地图的左上角和右下角:不需要很精确,但确保它们在墙内。现在通过Inspector检查它们的position就可以知道地图的大致边界。
-
从food.gd中删除关于位置的指定。现在它的_ready()方法应该是这样的:
func _ready():
$Sprite2D.modulate = Color("#9c4c2e") #设置颜色
scale = Vector2(0.3, 0.3)
- 在level.gd中准备一个方法来随机生成食物:
func generate_random_foods():
for i in range(500):
var food = food_scene.instantiate()
var tl = $MarkerTopLeft.position
var br = $MarkerBottomRight.position
food.position = Vector2(randi_range(tl.x, br.x), randi_range(tl.y, br.y))
$Foods.add_child(food)
食物总数可以根据地图大小和实际需要来调整。不用太在意代码逻辑,因为正式游戏里不会使用这种方式来生成食物。
在_ready()中调用该方法即可,就像这样:
func _ready():
# 初始化食物
generate_random_foods()
-
为了方便观察,给Level场景挂一个Camera2D结点,调整zoom确保镜头能包含整张地图。
如图,紫色的框是镜头范围。 -
继续在_ready()中修改,禁用玩家,只创建一个敌人,之后运行游戏,确保只有一条敌方蛇的游戏在正常工作,就可以开始调试敌人AI了。
# 创建玩家
#player = create_snake(false, 0)
snake_list.append(player) #需要一个空玩家来占位
# 创建敌人
for i in range(1): #只创建一个敌人
var enemy = create_snake(true, i+1)
snake_list.append(enemy)
var board = board_scene.instantiate()
$UI/Panels.get_child(i+1).add_child(board)
【基础行为】
控制蛇的运动只需要控制蛇头的运动。控制蛇头的运动只需要改变运动方向。
简单的觅食:
-
来到Enemy场景,新增一个Area2D结点,命名为DetectCircle(探测圈)。为这个Area2D新增一个圆形的CollisionShape2D,范围要比蛇头大。操作时,可以先把SnakeHead隐藏(点击结点旁边的眼睛图标),扩大CollisionShape之后再显示蛇头。之后,让DetectCircle成为SnakeHead的子结点,这样它就会跟着蛇头走。
这个探测圈就是蛇头的视野范围。
-
在DetectCircle的Inspector中调整碰撞层,Mask需要设置成1和3,确保它能感知到其他蛇(Layer1)和食物(Layer3)。
-
有了检测圈,就可以通过
get_overlapping_areas()
来检测圈内的食物。不过这里有个问题:除了食物,SnakeBody也是Area。为了区分两者,可以使用组(Group)。在Food场景中,从右侧Node选项卡里找到Groups,输入food并点击Add(或者直接Enter)。
现在Food属于Group “food”了。实际上Group更像是Tag,一个场景可以属于多个组,一个组可以包含多个场景。
-
将DetectCircle设置为唯一名称访问(Access as Unique Name)。修改enemy.gd:
extends Snake
func _ready():
$SnakeHead.modulate = Color.CADET_BLUE
super()
detect()
func _physics_process(delta):
if alive:
#$SnakeHead.direction = Vector2.DOWN #注释掉这句,现在通过detect方法指定方向
$SnakeHead.rotation = $SnakeHead.direction.angle()
$SnakeHead.handle_collision(delta)
super(delta)
func handle_head_death():
$SnakeHead.queue_free()
func detect():
if alive:
var areas : Array = %DetectCircle.get_overlapping_areas()
var foods = areas.filter(func(area):return area.is_in_group("food"))
if foods.size() > 0 :
# 蛇头指向找到的第一个食物
$SnakeHead.direction = (foods[0].global_position - $SnakeHead.global_position).normalized()
get_tree().create_timer(1).timeout.connect(detect) #每当超时,调用自身
detect()
方法每秒检测一次探测圈内的Area,将其中属于“food”组的areas过滤出来,然后简单地将蛇头指向这个列表的第一个对象。
var foods = areas.filter(func(area):return area.is_in_group("food"))
使用了匿名函数。filter里匿名函数的返回值应当是一个bool,Array的filter函数本身将返回一个Array,其将源Array中符合条件(布尔值为真)的项过滤出来。
现在运行游戏,敌方蛇看上去聪明了一点,但不多。它会自己转向寻找食物,但不会分辨食物的密集和稀疏,且会一头撞上墙。(下图为了方便观察,打开了Debug中的Visible Collision Shapes)
简单的回避:
蛇至少需要躲两种东西:墙,以及其他蛇的蛇头和蛇身。先不考虑蛇头的情况(蛇头可能涉及到攻击或回避的权衡),蛇身是一种area,而墙作为tile,在碰撞方面属于一种body。
在detect()
方法中加上这几句,可以看到控制台打印出来的除了蛇头就是TileMap。
var bodies = %DetectCircle.get_overlapping_bodies()
for body in bodies:
print(body.name)
在这里有一个额外的问题,从get_overlapping_bodies()
得到的墙是TileMap整体而不是单个的墙图块,想要获得单个图块的位置是比较麻烦的。对此,可以使用不规则探测区域,使得无需知道墙的位置即可回避。
-
为Enemy的SnakeHead新增Area2D子结点,重命名为CollisionView。为CollisionView新增CollisionPolygon2D作为碰撞形状。设置CollisionView为唯一名称访问。CollisionView的碰撞层Mask应该设置为2和5(可以感知到SnakeBody和TileMap)。
-
点击CollisionPolygon2D,在2D场景界面绘制碰撞多边形。上方的三个按钮分别是添加点(绿色)、编辑点(蓝色)、删除点(红色)。
绘制一个扇形。不需要十分精确,可以通过Inspector精细调整。
-
这样就可以通过信号来探测墙了。将CollisionView的body_entered信号连接到Enemy,编辑相应的方法:
func _on_collision_view_body_entered(body):
$SnakeHead.direction = $SnakeHead.direction.rotated(-PI/2)
$SnakeHead.rotation = $SnakeHead.direction.angle()
print("Dodge!")
运行游戏,可以看到蛇拥有一定的躲避能力了,但有时还是会撞墙。
- 用同样的方法来躲避其他SnakeBody,只要加一个过滤,不关心属于自己的身体环节:
func _on_collision_view_area_entered(area):
if area.id != id:
print("Dodge for snakeBody!")
$SnakeHead.direction = $SnakeHead.direction.rotated(-PI/2)
$SnakeHead.rotation = $SnakeHead.direction.angle()
- 现在将预生成的敌人数量改为2或者3,运行游戏,可以看到蛇会尝试躲避其他蛇(控制台将打印"Dodge for snakeBody!"),虽然有时会失败。
【使用状态】
方向时而需要保持不变,时而需要突变,时而需要试探性缓变。实际上,更合适的方法是逐帧确认是否变化、变化多少,并且将觅食和回避区分成不同的状态。
状态:
每帧更新时,敌方蛇要么在调用觅食方法,要么在调用回避方法,不会发生冲突。此时只要将初始状态设置为觅食,并且在CollisionView探测到危险时切换到回避状态。
enum State {FORAGING, DODGING}
var current_state : State = State.FORAGING
func _physics_process(delta):
if alive:
match current_state:
State.FORAGING:
forage()
State.DODGING:
dodge()
$SnakeHead.rotation = $SnakeHead.direction.angle()
$SnakeHead.handle_collision(delta)
super(delta)
func _on_collision_view_body_entered(_body):
current_state = State.DODGING
func _on_collision_view_area_entered(area):
if area.is_in_group("snake_body") && area.id != id:
current_state = State.DODGING
if area.is_in_group("burst_unit"):
current_state = State.DODGING
觅食状态:
改写detect()
方法,因为稍后会逐帧调用,无需在末尾调用自身。设置一个变量来储存探测到的第一个食物,只有“未保存目标食物”和“目标食物被吃掉”的情况下才会在探测圈里有食物时改变方向。
var target_food:Node
func forage():
if not is_instance_valid(target_food):
var areas : Array = %DetectCircle.get_overlapping_areas()
var foods = areas.filter(func(area):return area.is_in_group("food"))
if foods.size() > 0 :
# 蛇头指向找到的第一个食物
target_food = foods[0]
$SnakeHead.direction = (target_food.global_position - $SnakeHead.global_position).normalized()
回避状态:
每当调用回避方法,进行一点转向。这个转角不必太大,因为转向是逐帧调用的。用之前的方法计算出可能存在的危险,如果已经没有危险,就切换回觅食状态
const DODGE_ANGLE:float = PI/10
func dodge():
$SnakeHead.direction = $SnakeHead.direction.rotated(DODGE_ANGLE)
var areas : Array = %CollisionView.get_overlapping_areas()
var snake_bodies = areas.filter(func(area):
return area.is_in_group("snake_body") && area.id != id)
var burst_units = areas.filter(func(area):
return area.is_in_group("burst_unit"))
var bodies : Array = %CollisionView.get_overlapping_bodies()
var danger_num = snake_bodies.size() + burst_units.size() + bodies.size()
if danger_num == 0:
current_state = State.FORAGING