Godot实战-SnakeWar(10)

之前使用墙壁作为障碍时,我遇到了一个问题:墙之间留下的空间太窄,不利于大蛇发挥;空间太大,又对小蛇没意义。与此同时,随机分布的食物也很无趣,我希望用一种方式同时解决两个问题,那就是同时兼任障碍和食物来源的资源单位。

【创建不同的资源单位】

【“爆浆”单元!】

资源单位由某种基本单元构成,它占据一定位置,像蛇身一样会在接触蛇头时杀死蛇,但是自己碰到蛇(蛇身)时会死亡,并变成数个食物。

  1. 新建一个场景,根节点Area2D,重命名为BurstUnit,保存场景。

  2. 根结点下应当有一个精灵图,一个碰撞形状,一系列爆出食物的备选位点。实际上,完全可以把SnakeBody下的所有子结点直接复制过来。
    请添加图片描述

  3. 附加脚本burst_unit.gd。考虑脚本里需要什么,首先是generate_food_pos(),这是通用的。其次是关于body和area的碰撞处理,body_entered检测的是蛇头和墙,area_entered检测的是蛇身、食物、其他BurstUnit。暂时只考虑蛇的问题,body_entered调用body的crush杀死对方,area_entered则杀死自己。

extends Area2D

signal unit_burst

func generate_food_pos(num : int) -> Array:  #这个方法进行了简化
	var pos_list = []
	var points = $SpawnPoints.get_children()
	points.shuffle()
	for point in points.slice(0, num):
		pos_list.append(point.global_position)
		
	return pos_list

func _on_body_entered(body):
	if body.has_signal("crush"):
		body.crush.emit()

func _on_area_entered(area):
	if area.is_in_group("snake_body"):
		unit_burst.emit()
  1. 要记得修改碰撞层和遮罩:Layer是2,Mask是1和2(能看到蛇头、蛇身、其他BurstUnit)。

  2. 实际上,可以让SnakeBody继承BurstUnit,再添加自己的独有属性和方法。不过因为涉及到的逻辑比较简单,而且两者可能会有不同的行为逻辑,这里暂时先不折腾了。

【灌木】

这种资源单位由数排BurstUnit组成,会以固定频率向斜方向往复运动。

  1. 新建场景,根节点Node2D,重命名为Bush。新增一个测试用的Camera2D作为子结点,这样运行场景时就能以(0,0)为窗口中心。

  2. 新增脚本bush.gd。先尝试简单地在_ready()中实例化一个BurstUnit:

extends Node2D

var unit_scene = preload("res://Scenes/burst_unit.tscn")

func _ready():
	var unit = unit_scene.instantiate()
	unit.modulate = Color.AQUAMARINE
	
	add_child(unit)
  1. 运行场景,能看到一个指定颜色的BurstUnit出现在正中间。
    请添加图片描述

  2. 现在考虑放置一排BurstUnit,每个Unit之间的间隔刚好是圆的直径。问题在于如何获取直径。

获取精灵图大小:

  1. 在burst_unit.gd中增加一个方法:
func get_diameter() -> float:
	return $Sprite2D.get_rect().size.x

get_rect().size将返回精灵图所占据矩形的宽和长。圆形所占据矩形是一个正方形,任取其一即可得到直径。

  1. 在bush.gd中尝试获取直径:
func _ready():
	var unit = unit_scene.instantiate()
	unit.modulate = Color.AQUAMARINE
	print(unit.get_diameter())
	unit.scale = Vector2(0.5, 0.5) #改变缩放
	print(unit.get_diameter())
	
	add_child(unit)

尝试运行场景。理论上,改变缩放应该会改变直径,但实际上两次打印结果都是128,也就是原始图片的宽(128px)。

  1. 所以,需要修改获取直径方法,只要乘上scale即可:
func get_diameter() -> float:
	return $Sprite2D.get_rect().size.x * scale.x
  1. 重新运行Bush场景,可以看到打印结果分别是128和64。

成行成列:

  1. 现在,为bush.gd新增几个属性:行数,列数,大小,颜色。然后在初始化时按行数列数整齐排列所有生成的BurstUnit:
extends Node2D

var unit_scene = preload("res://Scenes/burst_unit.tscn")
var color : Color = Color.GREEN
var size : float = 1
var row_num : int = 2
var col_num : int = 5

func _ready():
	var diameter = init_unit().get_diameter()
	
	for i in range(row_num):
		for j in range(col_num):
			var unit = init_unit()
			unit.position = Vector2(j*diameter, i*diameter)
			add_child(unit)
	
func init_unit():
	var unit = unit_scene.instantiate()
	unit.scale = Vector2(size, size)
	unit.modulate = color
	return unit
  1. 运行场景,可以看到整齐排布的绿色的圆。
    请添加图片描述
    如果将size改为0.5,则BurstUnit也随之缩小,但整体仍然维持紧密排布。
    请添加图片描述

  2. 这样排布看起来有点呆板。最好让每一行错开半个直径的位置,并且更加紧凑:

	for i in range(row_num):
		for j in range(col_num):
			var unit = init_unit()
			var offset = i * diameter / 2
			unit.position = Vector2(j*diameter+offset, i*diameter*0.85)
			add_child(unit)

运行场景。现在看起来好多了。
请添加图片描述

摇摆:

  1. 为bush.gd新增两个变量:
    var direction_list = [Vector2(1,1), Vector2(-1,1), Vector2(-1,-1), Vector2(1,-1)]
    var padding : int = 0

新增一个摇摆(振荡)方法,每隔一秒向某一方向运动,一秒后复位。

func oscillate(direction:Vector2):
	position += direction * padding  #移位
	await get_tree().create_timer(1).timeout
	position -= direction * padding  #复位
	await get_tree().create_timer(1).timeout
	oscillate(direction)

最后,在_ready里计算padding并调用摇摆方法。

	padding = diameter * 0.3
	var direction = direction_list[randi()%direction_list.size()]
	oscillate(direction)

每个新生成的灌木将得到一个随机方向,并且在整个生命周期里只会沿着这个方向摇摆。

  1. 现在运行场景,会发现灌木并没有动,因为摄像机也在跟着动。把测试用的Camera2D删掉,重新运行场景,可以看到灌木的斜向摇摆。每次重新运行场景(重新生成灌木),摇摆方向都会有所不同。
    请添加图片描述

死亡处理:

只要一个单元死亡,整个灌木都会死亡。这部分的处理和蛇身很像。

  1. 为bush.gd新增一个信号signal burst_to_foods(pos_list:Array)。新增一个变量var value : int = 3,表示每个BurstUnit死亡后爆出的食物数量。

  2. _ready()里初始化每个BurstUnit时,连接burst信号到灌木:
    unit.unit_burst.connect(handle_death)

  3. 写死亡处理方法:

func handle_death():
	for unit in get_children():
		burst_to_foods.emit(unit.generate_food_pos(value))
	queue_free()
  1. 在关卡中,试着在_ready()里放入一个灌木:
	var bush_scene = preload("res://Scenes/bush.tscn")
	var bush = bush_scene.instantiate()
	bush.burst_to_foods.connect(handle_burst)
	add_child(bush)

要注意别让bush的初始位置和SpawnPoints中任何一个出生点重合。

  1. 运行游戏,可以看到关卡中摇摆的灌木。蛇头撞到灌木,蛇会死亡并变成食物。灌木碰到蛇身,灌木会死亡并变成食物。

  2. 然而,敌方蛇一旦靠近灌木,就会报错并中止游戏。根据报错信息,出错的地方在Enemy对Area的躲避方法:

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()

同为Area,SnakeBody有id,而灌木没有。现在可以使用比较粗糙的方式:为BurstUnit添加组“burst_unit”,然后据此进行过滤:

func _on_collision_view_area_entered(area):
	if area.is_in_group("snake_body") && area.id != id:
		$SnakeHead.direction = $SnakeHead.direction.rotated(-PI/2)
		$SnakeHead.rotation = $SnakeHead.direction.angle()
	if area.is_in_group("burst_unit"):
		$SnakeHead.direction = $SnakeHead.direction.rotated(-PI/2)
		$SnakeHead.rotation = $SnakeHead.direction.angle()
  1. 现在重新运行游戏(最好把敌方蛇的出生点放得离灌木近一点),可以看到敌方蛇靠近灌木时不再报错,并且会尝试避开。

  2. 先把level.gd的_ready()中测试用的灌木注释掉。记得保存。

【花】

这种资源单位由一个“核”为中心,随时间流逝一圈一圈向外增长。在不增长的时候,花会缓慢放大缩小,像呼吸一样。

资源单位超类:

考虑花需要什么。同样以Node2D为根节点,需要preload的unit_scene(BurstUnit场景),需要size、color和value,需要burst_to_foods信号。因此,可以制作一个拥有通用功能的超类FoodSource,再让所有资源单位继承它。

  1. 新建场景,根节点Node2D,重命名为FoodSource,保存。

  2. 附加脚本food_souce.gd,从bush.gd中把有用的部分抄过来,稍作修改:
    因为布局总是需要用到直径,所以在这里作为属性;
    size和color的调整单独列为方法,而不是混在初始化里使用。

extends Node2D
class_name FoodSource #记得设置类名

var unit_scene : PackedScene = preload("res://Scenes/burst_unit.tscn")
var value : int = 3
var diameter : float = 0

signal burst_to_foods(pos_list:Array)

func set_diameter():
	diameter = unit_scene.instantiate().get_diameter()

func set_size(size:float):
	scale = Vector2(size, size)

func change_color(color:Color):
	for unit in get_children():
		unit.modulate = color
  1. 回到Bush场景。因为场景下没有子结点,所以不需要继承场景,只要更改脚本bush.gd。
extends FoodSource #继承自超类

var row_num : int = 2
var col_num : int = 5
var direction_list = [Vector2(1,1), Vector2(-1,1), Vector2(-1,-1), Vector2(1,-1)]
var padding : int = 0

func _ready():
	set_diameter()
	
	for i in range(row_num):
		for j in range(col_num):
			var unit = unit_scene.instantiate()
			var offset = i * diameter / 2
			unit.position = Vector2(j*diameter+offset, i*diameter*0.85)
			unit.unit_burst.connect(handle_death)
			add_child(unit)
	
	padding = diameter * scale.x * 0.3 #因为现在是整体缩放灌木场景,要乘以缩放
	var direction = direction_list[randi()%direction_list.size()]
	oscillate(direction)
	
func oscillate(direction:Vector2):
	position += direction * padding  #移位
	await get_tree().create_timer(1).timeout
	position -= direction * padding  #复位
	await get_tree().create_timer(1).timeout
	oscillate(direction)

func handle_death():
	for unit in get_children():
		burst_to_foods.emit(unit.generate_food_pos(value))
	queue_free()
  1. 运行Bush场景,可以看到灌木的行为并未改变。
    请添加图片描述

生成花瓣:

花瓣围绕核,一圈圈生成,第一圈是6个,第二圈是12个,以此类推。可以想象成六边形的紧密排布。

  1. 新建场景,根节点Node2D,重命名为Flower。链接子场景BurstUnit,重命名为Core。新增子结点Camera2D方便观察,保存场景。

  2. 为根节点附加空脚本flower.gd,extends FoodSource。

extends FoodSource

var layer_num : int = 4

func _ready():
	set_diameter()
	for i in range(layer_num):
		grow(i+1)
	
	change_color(Color.DARK_ORCHID)
	set_size(0.3)

func grow(layer_index:int): #Core是第0层
	var total_num = layer_index * 6
	var a = 2 * PI / total_num #计算每个花瓣分得的弧度
	
	for i in range(total_num):
		var petal = unit_scene.instantiate()
		petal.position = Vector2(cos(a*i)*diameter*layer_index,sin(a*i)*diameter*layer_index)
		add_child(petal)
  1. 运行场景,这是4层花瓣的效果,看起来还不错:
    请添加图片描述

呼吸:

花的呼吸很简单,就是两个补间动画,循环调用。

func breathe():
	var size = scale.x
	var tween = get_tree().create_tween()
	tween.tween_property(self, "scale", Vector2(size*1.1, size*1.1), 1)
	tween.tween_property(self, "scale", Vector2(size, size), 1)
	await tween.finished
	breathe()
func _ready():
	value = 2
	set_diameter()
	for i in range(layer_num):
		grow(i+1)
	
	set_size(0.3)
	breathe()
	change_color(Color.DARK_ORCHID)

注意,如果先breathe()再set_size(),花将会一下子膨胀到scale(1,1),即默认缩放,然后开始呼吸。所以这两个都不应该放在_ready(),而是在关卡场景创建花时依次调用。为防止出错,可以绑定为一个方法:

func init_source(size:float, color:Color):
	set_diameter()
	for i in range(layer_num):
		grow(i+1)
	
	set_size(size)
	change_color(color)
	breathe()	

实际上,可以在Bush脚本里做相同的事情。

func init_source(size:float, color:Color):
	set_diameter()
	
	for i in range(row_num):
		for j in range(col_num):
			var unit = unit_scene.instantiate()
			var offset = i * diameter / 2
			unit.position = Vector2(j*diameter+offset, i*diameter*0.85)
			unit.unit_burst.connect(handle_death)
			add_child(unit)
	
	set_size(size)
	change_color(color)
	padding = diameter * scale.x * 0.3
	var direction = direction_list[randi()%direction_list.size()]
	oscillate(direction)

所以,可以把公共部分放在超类FoodSource的脚本里,在子类中super()调用。

func init_source(size:float, color:Color):
	set_size(size)
	change_color(color)

现在就不需要_ready()了,在关卡中创建时init_source()即可。作为测试目的,当前仍需要在初始化时调用一下init方法。

func _ready():
	init_source(0.3, Color.BLUE_VIOLET)

生长:

  1. 新增一个变量var growth_interval : int = 3 #生长间隔,/s,编写自动生长方法:
func auto_grow():
	await get_tree().create_timer(growth_interval).timeout
	grow(layer_num + 1)
	layer_num += 1
	auto_grow()

然后在init里调用:

func init_source(size:float, color:Color):
	set_diameter()
	for i in range(layer_num):
		grow(i+1)
	
	super(size, color)
	breathe()	
	auto_grow()

这样做会让新增的花瓣层变成默认颜色(白色),所以还要额外修改一下grow()方法创建花瓣的部分:

var petal = unit_scene.instantiate()
petal.modulate = $Core.modulate  #与核同色
  1. 运行场景,可以看到花每隔三秒就扩展一层。这个速度太快了,我更希望它在层数少的时候扩展得较快,而层数增加时扩展变慢。

  2. 现在,不使用固定的生长间隔,而是设置一个倍增系数:
    var interval_ratio : float = 2.5 #生长间隔倍增系数
    改写auto_grow()

func auto_grow():
	await get_tree().create_timer(interval_ratio*layer_num).timeout
	grow(layer_num + 1)
	layer_num += 1
	auto_grow()
  1. 运行场景,可以看到花越大(层数越多),生长越慢。

死亡处理:

当蛇身碰到外圈单元,花并不会直接被杀死,而是损失1-3层花瓣。

  1. 新增一个变量按层存放花瓣:var petal_list = [] # 所有花瓣,按层存放

  2. 修改grow()方法:

func grow(layer_index:int): #Core是第0层
	var total_num = layer_index * 6
	var a = 2 * PI / total_num
	var petals = []  #新增的一层花瓣
	
	for i in range(total_num):
		var petal = unit_scene.instantiate()
		petal.modulate = $Core.modulate #修改颜色
		petal.position = Vector2(cos(a*i)*diameter*layer_index,sin(a*i)*diameter*layer_index)
		petal.unit_burst.connect(handle_death)  #连接信号
		add_child(petal)
		petals.append(petal) 
	petal_list.append(petals)
  1. 编写死亡处理方法:
func handle_death():
	var destroyed_num = randi() % 3
	destroyed_num = min(destroyed_num, layer_num)
	for i in range(destroyed_num):
		for petal in petal_list[layer_num-1]:
			burst_to_foods.emit(petal.generate_food_pos(value))
			petal.queue_free()
		petal_list.remove_at(layer_num-1)
		layer_num -= 1
  1. 注释掉flower.gd的_ready(),删掉测试用摄像机,保存。回到Level场景,在level.gd的_ready()中创建一朵花:
	var flower_scene = preload("res://Scenes/flower.tscn")
	var flower = flower_scene.instantiate()
	flower.layer_num = 1
	flower.value = 2
	
	flower.burst_to_foods.connect(handle_burst)
	add_child(flower)  #注意顺序,要先把flower添加到场景树
	flower.init_source(0.3, Color.BLUE_VIOLET)
  1. 运行游戏,可以看到蛇会撞死在花上,花碰到蛇身会消除一或多层花瓣并爆出食物。蛇可以守在花旁边一层层消除并等待花重新长出来。这说明实际游戏中应当给花设置较小的value和较大的interval_ratio。
    请添加图片描述

【风铃草】

这种资源单位类似于花,但每一圈只有6个BurstUnit,没有生长和呼吸行为,整体匀速自旋。

构造:

  1. 新建场景,根节点Node2D,重命名为Campanula,链接子场景BurstUnit,重命名为Core。新增子结点Camera2D用于测试,保存场景。

  2. 附加脚本campanula.gd,参照flower写构造方法:

extends FoodSource

var layer_num : int = 8

func _ready():
	init_source(0.3, Color.CORNFLOWER_BLUE)

func init_source(size:float, color:Color):
	set_diameter()
	form()
	super(size, color)

func form():
	var offset = PI/20  #修改offset以调节弯曲程度
	for i in range(layer_num): #对每一层
		for j in range(6):  #每层有6个
			var unit = unit_scene.instantiate()
			var a = j*PI/3 + offset*i
			unit.position = Vector2(cos(a)*diameter*i,sin(a)*diameter*i)
			unit.unit_burst.connect(handle_death)
			add_child(unit)

func handle_death():
	pass
  1. 运行场景,可以看到排布基本符合要求,但由于offset的存在,外层Unit会显得离前一个太远。
    请添加图片描述

  2. 为了减少空隙,每一层的“半径”应当不是随层数线性增加,而是增加量不断减少(换言之,斜率不断变小)。可以将一次函数改成二次函数来达成这一目的,稍微修改算式:

var a = j*PI/3 + offset*i
var r = diameter*i*(1-0.04*i)
unit.position = Vector2(cos(a) * r,sin(a) * r)

请添加图片描述

这个式子最多允许生成12层,再多就要往里弯了。这在多数情况下应该够用。

旋转:

自旋运动非常简单。只需要新增一个变量var rot_speed : float = 0.8,然后在_process()里改变rotation:

func _process(delta):
	rotation += delta * rot_speed

运行场景,可以看到风铃草匀速自转。

死亡处理:

当风铃草的某个单元碰到蛇身,该单元所在的一束单元都会死亡。若所有束死亡,则整个风铃草死亡。

  1. 首先需要把所有单元按束存储在数组里,类似花。因此微调循环体,之前是一圈一圈生成,每一圈(层)生成6个。现在要改成每一位点由近及远依次生成layer_num个单元,共6个位点。只需要调换一下顺序(不改i,j,不然算式里的变量要跟着改):
var unit_list = []

func form():
	var offset = PI/20  #修改offset以调节弯曲程度
	
	for j in range(6):  #6个束
		var units = []  #储存一束
		for i in range(layer_num):  #对每一层
			var unit = unit_scene.instantiate()
			var a = j*PI/3 + offset*i
			var r = diameter*i*(1-0.04*i)
			unit.position = Vector2(cos(a) * r,sin(a) * r)
			unit.unit_burst.connect(handle_death)
			units.append(unit)
			add_child(unit)
		unit_list.append(units)
  1. 接下来,每当收到单元发出的unit_burst,就要连带其所在的一束一起殉爆。花可以确认被碰到的单元(花瓣)总在最外层,但风铃草的单元并不能便捷地明确其所在的束。从常理考虑这里就需要id之类的属性来确认身份了,但还存在一个讨巧的方式:

    先删除发出burst信号的那个单元,然后找到不满编的束,整束摧毁。

  2. 首先需要修改burst_unit.gd。

signal unit_burst(unit) 

func _on_area_entered(area):
	if area.is_in_group("snake_body"):
		unit_burst.emit(self) #携带参数是BurstUnit自身
  1. 回到campanula.gd,写死亡处理方法:
func handle_death(dead_unit):
	destroy_unit(dead_unit)
	
	# 找到不满编的束并摧毁
	for units in unit_list:
		var not_full = units.any(func(unit):
			return unit.is_queued_for_deletion()
		)
		if not_full:
			for unit in units:
				if unit != null:
					destroy_unit(unit)
			unit_list.erase(units)  #从数组中删除束
	
	if unit_list.size() == 0:
		queue_free()

func destroy_unit(unit):
	burst_to_foods.emit(unit.generate_food_pos(value))
	unit.queue_free()

因为删除使用了unit.queue_free(),在下面for循环检查时,要检查(每一束)是否有unit在待删除队列中,如有,则这一束全部摧毁,并且从unit_list里排除。

  1. 删掉测试用摄像机,保存场景。然后在level.gd中添加一个风铃草:

  2. 运行游戏,可以看到风铃草在关卡中旋转。只要蛇身碰到其中一个单元,无论是末端的还是中间的,其所在的一束都会消失并爆出食物。但是,如果同时还添加了花和灌木,就会发现这两者可以杀死蛇(发生蛇头碰撞时),却不能被蛇杀死(发生蛇身碰撞时)。这是因为修改了信号,却没有修改对应方法,导致死亡处理机制失效。

  3. 在food_source.gd中提供虚方法:

func handle_death(dead_unit):
	pass

然后分别在bush.gd和flower.gd的死亡处理方法中补上参数:因为不需要使用,可以在前面加斜杠标明,就像godot建议的那样。

func handle_death(_dead_unit):
  1. 现在按照之前的方式添加一个灌木、一个花、一个风铃草(修改position从而让它们的位置错开),运行游戏。可以看到灌木和花都可以正常地被摧毁了。

【整理代码】

重新审视三种资源单位,现在可以考虑它们的共性并且对代码做一点优化。

【使用@export】

  1. 回到food_source.gd,把size和color作为公有属性,将需要修改的属性都export,这样继承FoodSource的其他场景(灌木、花、风铃草)就可以单独调整。
@export var value : int = 1
@export var size : float = 0.5
@export var color : Color = Color.WHITE
  1. 顺便,重新处理size设置方法:
func set_size():
	scale = Vector2(size, size)
  1. 在灌木、花和风铃草的Script中也要做出对应修改,把set_size()的参数去掉。

【重新处理颜色逻辑】

  1. 回到BurstUnit场景,在Inspector-Node-Groups中添加组“burst_unit”。这样如果某种FoodSource添加了其他结点,就不会在change_color时报错或者跟着改颜色。

  2. bush.gd中,删去init_source()里的改颜色:
    #modulate = color

  3. flower.gd中,同样删去init_source()里的改颜色。在_ready()里修改Core的颜色:
    $Core.modulate = color

  4. campanula.gd中,删去init_source()里的改颜色。在form()或者ready()里修改颜色:
    modulate = color

【注意重复的代码】

经过修改,init_source()已经不需要包含继承过来的size和color处理。共有的set_diameter()可以丢进_ready()里。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值