Godot实战-SnakeWar(10)
之前使用墙壁作为障碍时,我遇到了一个问题:墙之间留下的空间太窄,不利于大蛇发挥;空间太大,又对小蛇没意义。与此同时,随机分布的食物也很无趣,我希望用一种方式同时解决两个问题,那就是同时兼任障碍和食物来源的资源单位。
【创建不同的资源单位】
【“爆浆”单元!】
资源单位由某种基本单元构成,它占据一定位置,像蛇身一样会在接触蛇头时杀死蛇,但是自己碰到蛇(蛇身)时会死亡,并变成数个食物。
-
新建一个场景,根节点Area2D,重命名为BurstUnit,保存场景。
-
根结点下应当有一个精灵图,一个碰撞形状,一系列爆出食物的备选位点。实际上,完全可以把SnakeBody下的所有子结点直接复制过来。
-
附加脚本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()
-
要记得修改碰撞层和遮罩:Layer是2,Mask是1和2(能看到蛇头、蛇身、其他BurstUnit)。
-
实际上,可以让SnakeBody继承BurstUnit,再添加自己的独有属性和方法。不过因为涉及到的逻辑比较简单,而且两者可能会有不同的行为逻辑,这里暂时先不折腾了。
【灌木】
这种资源单位由数排BurstUnit组成,会以固定频率向斜方向往复运动。
-
新建场景,根节点Node2D,重命名为Bush。新增一个测试用的Camera2D作为子结点,这样运行场景时就能以(0,0)为窗口中心。
-
新增脚本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)
-
运行场景,能看到一个指定颜色的BurstUnit出现在正中间。
-
现在考虑放置一排BurstUnit,每个Unit之间的间隔刚好是圆的直径。问题在于如何获取直径。
获取精灵图大小:
- 在burst_unit.gd中增加一个方法:
func get_diameter() -> float:
return $Sprite2D.get_rect().size.x
get_rect().size
将返回精灵图所占据矩形的宽和长。圆形所占据矩形是一个正方形,任取其一即可得到直径。
- 在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)。
- 所以,需要修改获取直径方法,只要乘上scale即可:
func get_diameter() -> float:
return $Sprite2D.get_rect().size.x * scale.x
- 重新运行Bush场景,可以看到打印结果分别是128和64。
成行成列:
- 现在,为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
-
运行场景,可以看到整齐排布的绿色的圆。
如果将size改为0.5,则BurstUnit也随之缩小,但整体仍然维持紧密排布。
-
这样排布看起来有点呆板。最好让每一行错开半个直径的位置,并且更加紧凑:
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)
运行场景。现在看起来好多了。
摇摆:
- 为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)
每个新生成的灌木将得到一个随机方向,并且在整个生命周期里只会沿着这个方向摇摆。
- 现在运行场景,会发现灌木并没有动,因为摄像机也在跟着动。把测试用的Camera2D删掉,重新运行场景,可以看到灌木的斜向摇摆。每次重新运行场景(重新生成灌木),摇摆方向都会有所不同。
死亡处理:
只要一个单元死亡,整个灌木都会死亡。这部分的处理和蛇身很像。
-
为bush.gd新增一个信号
signal burst_to_foods(pos_list:Array)
。新增一个变量var value : int = 3
,表示每个BurstUnit死亡后爆出的食物数量。 -
在
_ready()
里初始化每个BurstUnit时,连接burst信号到灌木:
unit.unit_burst.connect(handle_death)
-
写死亡处理方法:
func handle_death():
for unit in get_children():
burst_to_foods.emit(unit.generate_food_pos(value))
queue_free()
- 在关卡中,试着在
_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中任何一个出生点重合。
-
运行游戏,可以看到关卡中摇摆的灌木。蛇头撞到灌木,蛇会死亡并变成食物。灌木碰到蛇身,灌木会死亡并变成食物。
-
然而,敌方蛇一旦靠近灌木,就会报错并中止游戏。根据报错信息,出错的地方在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()
-
现在重新运行游戏(最好把敌方蛇的出生点放得离灌木近一点),可以看到敌方蛇靠近灌木时不再报错,并且会尝试避开。
-
先把level.gd的
_ready()
中测试用的灌木注释掉。记得保存。
【花】
这种资源单位由一个“核”为中心,随时间流逝一圈一圈向外增长。在不增长的时候,花会缓慢放大缩小,像呼吸一样。
资源单位超类:
考虑花需要什么。同样以Node2D为根节点,需要preload的unit_scene(BurstUnit场景),需要size、color和value,需要burst_to_foods信号。因此,可以制作一个拥有通用功能的超类FoodSource,再让所有资源单位继承它。
-
新建场景,根节点Node2D,重命名为FoodSource,保存。
-
附加脚本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
- 回到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()
- 运行Bush场景,可以看到灌木的行为并未改变。
生成花瓣:
花瓣围绕核,一圈圈生成,第一圈是6个,第二圈是12个,以此类推。可以想象成六边形的紧密排布。
-
新建场景,根节点Node2D,重命名为Flower。链接子场景BurstUnit,重命名为Core。新增子结点Camera2D方便观察,保存场景。
-
为根节点附加空脚本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)
- 运行场景,这是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)
生长:
- 新增一个变量
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 #与核同色
-
运行场景,可以看到花每隔三秒就扩展一层。这个速度太快了,我更希望它在层数少的时候扩展得较快,而层数增加时扩展变慢。
-
现在,不使用固定的生长间隔,而是设置一个倍增系数:
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-3层花瓣。
-
新增一个变量按层存放花瓣:
var petal_list = [] # 所有花瓣,按层存放
。 -
修改
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)
- 编写死亡处理方法:
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
- 注释掉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)
- 运行游戏,可以看到蛇会撞死在花上,花碰到蛇身会消除一或多层花瓣并爆出食物。蛇可以守在花旁边一层层消除并等待花重新长出来。这说明实际游戏中应当给花设置较小的value和较大的interval_ratio。
【风铃草】
这种资源单位类似于花,但每一圈只有6个BurstUnit,没有生长和呼吸行为,整体匀速自旋。
构造:
-
新建场景,根节点Node2D,重命名为Campanula,链接子场景BurstUnit,重命名为Core。新增子结点Camera2D用于测试,保存场景。
-
附加脚本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
-
运行场景,可以看到排布基本符合要求,但由于offset的存在,外层Unit会显得离前一个太远。
-
为了减少空隙,每一层的“半径”应当不是随层数线性增加,而是增加量不断减少(换言之,斜率不断变小)。可以将一次函数改成二次函数来达成这一目的,稍微修改算式:
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
运行场景,可以看到风铃草匀速自转。
死亡处理:
当风铃草的某个单元碰到蛇身,该单元所在的一束单元都会死亡。若所有束死亡,则整个风铃草死亡。
- 首先需要把所有单元按束存储在数组里,类似花。因此微调循环体,之前是一圈一圈生成,每一圈(层)生成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)
-
接下来,每当收到单元发出的
unit_burst
,就要连带其所在的一束一起殉爆。花可以确认被碰到的单元(花瓣)总在最外层,但风铃草的单元并不能便捷地明确其所在的束。从常理考虑这里就需要id之类的属性来确认身份了,但还存在一个讨巧的方式:先删除发出burst信号的那个单元,然后找到不满编的束,整束摧毁。
-
首先需要修改burst_unit.gd。
signal unit_burst(unit)
func _on_area_entered(area):
if area.is_in_group("snake_body"):
unit_burst.emit(self) #携带参数是BurstUnit自身
- 回到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里排除。
-
删掉测试用摄像机,保存场景。然后在level.gd中添加一个风铃草:
-
运行游戏,可以看到风铃草在关卡中旋转。只要蛇身碰到其中一个单元,无论是末端的还是中间的,其所在的一束都会消失并爆出食物。但是,如果同时还添加了花和灌木,就会发现这两者可以杀死蛇(发生蛇头碰撞时),却不能被蛇杀死(发生蛇身碰撞时)。这是因为修改了信号,却没有修改对应方法,导致死亡处理机制失效。
-
在food_source.gd中提供虚方法:
func handle_death(dead_unit):
pass
然后分别在bush.gd和flower.gd的死亡处理方法中补上参数:因为不需要使用,可以在前面加斜杠标明,就像godot建议的那样。
func handle_death(_dead_unit):
- 现在按照之前的方式添加一个灌木、一个花、一个风铃草(修改position从而让它们的位置错开),运行游戏。可以看到灌木和花都可以正常地被摧毁了。
【整理代码】
重新审视三种资源单位,现在可以考虑它们的共性并且对代码做一点优化。
【使用@export】
- 回到food_source.gd,把size和color作为公有属性,将需要修改的属性都export,这样继承FoodSource的其他场景(灌木、花、风铃草)就可以单独调整。
@export var value : int = 1
@export var size : float = 0.5
@export var color : Color = Color.WHITE
- 顺便,重新处理size设置方法:
func set_size():
scale = Vector2(size, size)
- 在灌木、花和风铃草的Script中也要做出对应修改,把
set_size()
的参数去掉。
【重新处理颜色逻辑】
-
回到BurstUnit场景,在Inspector-Node-Groups中添加组“burst_unit”。这样如果某种FoodSource添加了其他结点,就不会在change_color时报错或者跟着改颜色。
-
bush.gd中,删去
init_source()
里的改颜色:
#modulate = color
-
flower.gd中,同样删去
init_source()
里的改颜色。在_ready()
里修改Core的颜色:
$Core.modulate = color
-
campanula.gd中,删去
init_source()
里的改颜色。在form()
或者ready()
里修改颜色:
modulate = color
【注意重复的代码】
经过修改,init_source()
已经不需要包含继承过来的size和color处理。共有的set_diameter()
可以丢进_ready()
里。