Godot实战-贪吃蛇(6)

【简单的敌人AI】

现在考虑让敌人行动起来。敌人应该拥有以下基本能力:

  1. 寻找食物
  2. 避开障碍(包括墙和其他蛇)

【准备工作】

现在只考察一条敌方蛇的运动。首先需要准备场地,并且调整一些参数,方便观察。

  1. 在Level场景中点击TileMap结点。通过下方面板的Rect和橡皮擦工具清除当前的测试地图,然后绘制一个更大的地图。注意地面和墙在不同的层。尝试在地图中间也绘制一些墙,最后的效果大概是这样:
    请添加图片描述

  2. 为Level场景添加两个Marker2D,拖动到地图的左上角和右下角:不需要很精确,但确保它们在墙内。现在通过Inspector检查它们的position就可以知道地图的大致边界。
    请添加图片描述

    请添加图片描述

  3. 从food.gd中删除关于位置的指定。现在它的_ready()方法应该是这样的:

func _ready():
	$Sprite2D.modulate = Color("#9c4c2e") #设置颜色
	scale = Vector2(0.3, 0.3)
  1. 在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()
  1. 为了方便观察,给Level场景挂一个Camera2D结点,调整zoom确保镜头能包含整张地图。
    请添加图片描述
    如图,紫色的框是镜头范围。

  2. 继续在_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)

请添加图片描述

【基础行为】

控制蛇的运动只需要控制蛇头的运动。控制蛇头的运动只需要改变运动方向。

简单的觅食:

  1. 来到Enemy场景,新增一个Area2D结点,命名为DetectCircle(探测圈)。为这个Area2D新增一个圆形的CollisionShape2D,范围要比蛇头大。操作时,可以先把SnakeHead隐藏(点击结点旁边的眼睛图标),扩大CollisionShape之后再显示蛇头。之后,让DetectCircle成为SnakeHead的子结点,这样它就会跟着蛇头走。
    请添加图片描述

    这个探测圈就是蛇头的视野范围。

  2. 在DetectCircle的Inspector中调整碰撞层,Mask需要设置成1和3,确保它能感知到其他蛇(Layer1)和食物(Layer3)。
    请添加图片描述

  3. 有了检测圈,就可以通过get_overlapping_areas()来检测圈内的食物。不过这里有个问题:除了食物,SnakeBody也是Area。为了区分两者,可以使用组(Group)。

    在Food场景中,从右侧Node选项卡里找到Groups,输入food并点击Add(或者直接Enter)。
    请添加图片描述

    现在Food属于Group “food”了。实际上Group更像是Tag,一个场景可以属于多个组,一个组可以包含多个场景。

  4. 将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整体而不是单个的墙图块,想要获得单个图块的位置是比较麻烦的。对此,可以使用不规则探测区域,使得无需知道墙的位置即可回避。

  1. 为Enemy的SnakeHead新增Area2D子结点,重命名为CollisionView。为CollisionView新增CollisionPolygon2D作为碰撞形状。设置CollisionView为唯一名称访问。CollisionView的碰撞层Mask应该设置为2和5(可以感知到SnakeBody和TileMap)。
    请添加图片描述

  2. 点击CollisionPolygon2D,在2D场景界面绘制碰撞多边形。上方的三个按钮分别是添加点(绿色)、编辑点(蓝色)、删除点(红色)。
    请添加图片描述
    绘制一个扇形。不需要十分精确,可以通过Inspector精细调整。
    请添加图片描述

  3. 这样就可以通过信号来探测墙了。将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!")

运行游戏,可以看到蛇拥有一定的躲避能力了,但有时还是会撞墙。

  1. 用同样的方法来躲避其他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()
  1. 现在将预生成的敌人数量改为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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值