GameTech/Godot 4.7 互动教程
目录示例游戏
第三部分 · 2D 实战 · 第 12 章

三消(下):下落、连锁与动画

消除之后的流程:双指针重力压缩、补充新宝石、用 await 把"交换 → 检测 → 消除 → 下落"串成连锁循环;Tween 的并行动画和 await finished、特殊宝石的链式触发、死局洗牌、计分倍数和星级评价。

上一章结束时,我们能找出棋盘上所有要消的格子。消掉之后格子空了,上面的宝石要掉下来,顶部要补新宝石,新宝石可能又凑成三连,于是再消、再掉,这就是连锁。这一章先把这个循环在逻辑层写清楚(不带任何动画,一瞬间算完),再用 await 和 Tween 把它一步一步演出来。

动手试试:把"宝石种类"调到 4,打开"自动演示",看一会儿:连锁会频繁出现,屏幕中间弹出"连锁 ×2""连锁 ×3",棋盘轻微震动。然后把"连锁间隔"调到 0.5、"下落 1 格时长"调到 0.3,每一轮"消除 → 下落 → 再检测"就能看得很清楚。

1. 重力压缩:双指针

消除之后,每一列里有若干空格散落在中间。我们要把这一列的宝石"往下压实":保持原来的上下顺序,全部挤到底部,空格全部留到顶部。

一个直观的做法是模拟重力:反复扫描,只要某颗宝石下面是空格,就把它往下挪一格,直到一整遍都没有可挪的。这样做是对的,但一颗宝石要落 5 格就要挪 5 次,整列最坏是 O(H²)。

更好的做法用两个指针,一遍完成:

  • read(读指针)从底部往上走,寻找宝石;
  • write(写指针)指向"下一颗宝石应该放的位置",初始在最底部。

read 遇到空格就跳过;遇到宝石,就把它搬到 write,然后 write 上移一格。因为 write 只在放下一颗宝石后才上移,它永远不会超过 read,搬过去的位置一定是空的或者就是它自己。

board.gd第 356–375 行每列只走一遍:O(H),整个棋盘 O(W·H)在工程中查看 ↗
## 重力压缩:每一列用两个指针从下往上走。
## read 找宝石,write 指向"下一个应该放宝石的位置"。遇到宝石就搬到 write,write 上移一格。
## 每列只走一遍,O(H);整个棋盘 O(W·H)。返回移动列表 [{from, to}],给动画用。
func collapse() -> Array[Dictionary]:
	var moves: Array[Dictionary] = []
	for x in width:
		var write := height - 1
		for read in range(height - 1, -1, -1):
			var from := Vector2i(x, read)
			if cells[idx(from)] == EMPTY:
				continue
			if read != write:
				var to := Vector2i(x, write)
				cells[idx(to)] = cells[idx(from)]
				special[idx(to)] = special[idx(from)]
				cells[idx(from)] = EMPTY
				special[idx(from)] = Special.NONE
				moves.append({"from": from, "to": to})
			write -= 1
	return moves

这个函数除了修改数组,还返回一个移动列表 [{from, to}, ...]。逻辑层不播动画,但它知道"哪颗宝石从哪格到了哪格",表现层拿着这个列表去移动对应的 Gem 节点。这是逻辑层和表现层之间唯一的接口:逻辑层算完以后,把"发生了什么"交出去。

说明:同样的双指针写法也是数组"原地删除元素"的标准解法(比如从数组里删掉所有 0,保持其余元素的顺序)。三消的压缩就是对每一列做"删除所有空格,然后在前面补空位"。

一列从上到下是 [A, 空, B, 空, 空](第 0~4 格)。压缩结束时,write 指针停在哪里?顶部有几个空格?

  1. write = 3,顶部 3 个空格
  2. write = 2,顶部 3 个空格
  3. write = 1,顶部 2 个空格
  4. write = 0,顶部 1 个空格

read 从第 4 格往上:4、3 是空格跳过;2 是 B,搬到 write=4,write 变 3;1 空格;0 是 A,搬到 write=3,write 变 2。结束时 write = 2,第 0、1、2 格是空的,共 write + 1 = 3 个。

2. 补充新宝石

压缩之后,空格只会出现在每列的顶部,所以补充很简单:数一数每列顶部有几个空格,填上随机颜色。

board.gd第 380–394 行在工程中查看 ↗
## 压缩之后,空位只会出现在每列顶部。把它们补满新宝石。
## 补充时不避开三连:新掉下来的宝石凑成三连,就是"天降连锁"。
## 返回 [{cell, kind, drop}],drop = 这一列补了几个,动画里新宝石从棋盘上方 drop 格处落下。
func refill() -> Array[Dictionary]:
	var spawns: Array[Dictionary] = []
	for x in width:
		var empty_count := 0
		while empty_count < height and cells[idx(Vector2i(x, empty_count))] == EMPTY:
			empty_count += 1
		for y in empty_count:
			var c := Vector2i(x, y)
			cells[idx(c)] = rng.randi_range(0, kinds - 1)
			special[idx(c)] = Special.NONE
			spawns.append({"cell": c, "kind": cells[idx(c)], "drop": empty_count})
	return spawns

注意两点:

  • 补充时不避开三连。开局生成要逐格排除,但补充不需要:新宝石凑成三连就是"天降连锁",是三消里最让人开心的部分。
  • 返回的 drop 是给动画用的。一列补了 3 颗,那这 3 颗新宝石就从棋盘上方 3 格的位置开始落,正好排成一串接着掉进来,看起来像是从棋盘外面一直连着的。棋盘节点 GemLayer 打开了 clip_contents,新宝石在进入棋盘之前是看不见的。

连锁出现的频率主要由宝石种类决定。关掉特殊宝石,在 8×8 棋盘上随机走 1000 步,统计每一步消除了几轮:

宝石种类34567
平均每步的消除轮数5.372.471.721.441.29
出现连锁(≥ 2 轮)的步78%59%43%30%22%
最长连锁52 轮14 轮7 轮6 轮5 轮

原因和上一章的开局概率一样:补进来的新宝石和周围凑成三连的概率大约和 1/k² 成正比,k 越小,每一轮"再来一次"的机会越大。3 种颜色时连锁几乎停不下来,游戏就失去了控制感;7 种时连锁很少,主要靠玩家自己找。

3. 先写一个没有动画的回合

把上面的零件串起来,一个回合是:交换 → 循环(找匹配 → 消除 → 压缩 → 补充)直到找不到匹配 → 检查死局。在逻辑层,这只是一个普通的 while 循环,一瞬间就跑完:

board.gd第 426–455 行同步版本:没有 await,没有动画在工程中查看 ↗
## 不带动画的完整一回合:交换 → 循环(检测 → 消除 → 压缩 → 补充)直到没有消除 → 死局洗牌。
## main.gd 用 await 做的是同一件事,只是每一步之间插入了动画。测试脚本直接调用这个函数。
func play_move(a: Vector2i, b: Vector2i) -> Dictionary:
	if not is_valid_swap(a, b):
		return {"ok": false}
	var bomb_swap := kind_at(a) == RAINBOW or kind_at(b) == RAINBOW
	swap(a, b)
	var extra: Array[Vector2i] = []
	if bomb_swap:
		extra = activate_color_bomb(a, b)
	var prefer: Array[Vector2i] = [b, a]
	var chain := 0
	var cleared := 0
	var spawned := 0
	while true:
		var groups := find_matches()
		if groups.is_empty() and extra.is_empty():
			break
		chain += 1
		var res := clear_matches(groups, prefer, extra)
		extra = []
		prefer = []
		cleared += res.cleared.size()
		spawned += res.spawned.size()
		collapse()
		refill()
	var shuffled := find_valid_moves().is_empty()
	if shuffled:
		shuffle()
	return {"ok": true, "chain": chain, "cleared": cleared, "spawned": spawned, "shuffled": shuffled}

先写这个同步版本有两个好处。第一,它就是游戏规则的"标准答案",后面带动画的版本必须和它做同样的事。第二,它可以直接拿来做压力测试:随机交换 1000 次,每回合结束检查几条不变量,也就是无论怎么玩都必须成立的条件:

tests/board_test.gd第 72–123 行1000 次随机交换,每回合后检查不变量在工程中查看 ↗
## 1000 次随机交换(一半随便挑相邻两格,一半从可行步里挑),每回合结束后检查不变量。
func _test_random_play(n: int) -> void:
	var rng := RandomNumberGenerator.new()
	rng.seed = 2024
	var b: Board
	var stats := {"ok": 0, "rejected": 0, "chain": 0, "spawned": 0, "shuffled": 0}
	for i in n:
		if i % 100 == 0:   # 每 100 步换一种棋盘配置
			b = Board.new(rng.randi_range(5, 10), rng.randi_range(5, 10), rng.randi_range(3, 7), rng.randi())
			b.fill_without_matches()
		var a := Vector2i(rng.randi_range(0, b.width - 1), rng.randi_range(0, b.height - 1))
		var c: Vector2i = a + [Vector2i.RIGHT, Vector2i.DOWN, Vector2i.LEFT, Vector2i.UP][rng.randi_range(0, 3)]
		if i % 2 == 0:
			var moves := b.find_valid_moves()
			var m: Array = moves[rng.randi_range(0, moves.size() - 1)]
			a = m[0]
			c = m[1]
		var before := b.cells.duplicate()
		var expect_ok := b.is_valid_swap(a, c)
		var res := b.play_move(a, c)
		_check(res.ok == expect_ok, "第 %d 步:play_move 和 is_valid_swap 不一致" % i)
		if not res.ok:
			stats.rejected += 1
			_check(b.cells == before, "第 %d 步:无效交换改动了棋盘" % i)
			continue
		stats.ok += 1
		stats.chain = maxi(stats.chain, res.chain)
		stats.spawned += res.spawned
		stats.shuffled += 1 if res.shuffled else 0
		_check_invariants(b, "第 %d 步" % i)
	print("随机交换 %d 次:有效 %d,被拒 %d,最长连锁 %d,生成特殊宝石 %d,死局洗牌 %d" % [
		n, stats.ok, stats.rejected, stats.chain, stats.spawned, stats.shuffled])
	_check(stats.ok > n / 3 and stats.rejected > 0, "有效/被拒的次数不合理")


func _check_invariants(b: Board, where: String) -> void:
	_check(b.cells.size() == b.width * b.height, where + ":格子数变了")
	for i in b.cells.size():
		var k := b.cells[i]
		var sp := b.special[i]
		_check(k != Board.EMPTY, where + ":回合结束后还有空格")
		_check((k >= 0 and k < b.kinds) or k == Board.RAINBOW, where + ":非法颜色 %d" % k)
		_check((k == Board.RAINBOW) == (sp == Board.Special.COLOR), where + ":同色炸弹和颜色不一致")
	_check(b.find_matches().is_empty(), where + ":回合结束后棋盘上还有三连")
	var moves := b.find_valid_moves()
	_check(not moves.is_empty(), where + ":回合结束后无步可走(应该已经洗牌)")
	# 可行步检测和"真的交换再扫描"结果一致
	for m in moves:
		b.swap(m[0], m[1])
		var real := not b.find_matches().is_empty() or b.kind_at(m[0]) == Board.RAINBOW or b.kind_at(m[1]) == Board.RAINBOW
		b.swap(m[0], m[1])
		_check(real, where + ":%s↔%s 被报告为可行步,但交换后没有三连" % [m[0], m[1]])

检查的不变量包括:回合结束后没有空格;没有残留的三连(否则说明连锁循环提前退出了);同色炸弹的颜色和特殊类型一致;一定有可走的步(否则说明洗牌没生效);find_valid_moves() 报告的每一步交换后真的能消。无效交换还要保证棋盘一个格子都没变。在本书的工程里运行这个脚本,输出是:

随机交换 1000 次:有效 582,被拒 418,最长连锁 30,生成特殊宝石 670,死局洗牌 2
BOARD TEST 通过(失败 0 项)

技巧:测试里每 100 步换一次棋盘配置(宽高 5~10、颜色 3~7),这样边界情况也能覆盖到:5×5 的小棋盘容易死局,3 种颜色的棋盘连锁很长。写游戏逻辑时,这种"随机操作 + 检查不变量"的测试往往比手写几个具体例子更容易发现问题。

4. 加上动画:用 await 组织流程

同步版本一瞬间就算完了,玩家什么也看不见。我们要在每一步之间插入动画:交换动画播完再判断,消除动画播完再下落,下落播完再检测下一轮。

如果没有 await,常见的写法是"状态机 + 回调":交换动画结束的信号连到 _on_swap_done,它里面开始消除动画,消除动画结束再连到 _on_clear_done……一个回合被拆成五六个函数,连锁循环还要靠状态变量来记住"现在是第几轮"。GDScript 的 await 可以让这段流程照着同步版本的样子写:

main.gd第 270–323 行和同步版本几乎一一对应,只是每一步后面多了 await 动画在工程中查看 ↗
## 一回合:交换 →(检测 → 消除 → 下落补充)循环 → 死局检查。
## 每个 await 都会把这个函数挂起,等动画播完再往下走。挂起期间 _busy 为 true,输入一律忽略。
func _play_turn(a: Vector2i, b: Vector2i) -> void:
	_busy = true
	_set_hint([])
	var bomb_swap := board.kind_at(a) == Board.RAINBOW or board.kind_at(b) == Board.RAINBOW
	board.swap(a, b)
	await _animate_swap(a, b)
	if not bomb_swap and board.find_matches().is_empty():
		board.swap(a, b)                    # 没有形成消除:逻辑上换回去
		await _animate_swap(a, b)           # 画面上也换回去
		_finish_turn()
		return
	_moves_used += 1
	moves_left -= 1
	var extra: Array[Vector2i] = []
	if bomb_swap:
		extra = board.activate_color_bomb(a, b)
		fx.ring(view.cell_to_pixel(b if board.kind_at(b) == Board.RAINBOW else a), view.cell_size * 4.0, Color.WHITE)
	var prefer: Array[Vector2i] = [b, a]    # 新的特殊宝石优先出现在玩家拖过去的位置
	chain = 0
	while not _rebuild_pending:
		var groups := board.find_matches()
		if groups.is_empty() and extra.is_empty():
			break
		chain += 1
		var res := board.clear_matches(groups, prefer, extra)
		extra = []
		prefer = []
		_add_score(res, groups)
		await _animate_clear(res)
		if _rebuild_pending:
			break
		var moves := board.collapse()
		var spawns := board.refill()
		await _animate_fall(moves, spawns)
		if chain_delay > 0.0:
			await get_tree().create_timer(chain_delay).timeout
	if not _rebuild_pending and board.find_valid_moves().is_empty():
		await _shuffle_board("没有可走的步了,自动洗牌")
	_finish_turn()


func _finish_turn() -> void:
	_busy = false
	_idle = 0.0
	if _rebuild_pending:
		_new_game()
		return
	_valid_count = board.find_valid_moves().size()
	_refresh_hud()
	if moves_left <= 0 and not _game_over:
		_game_over = true
		hud.show_result(score, target_score)

对照上一节的 play_move():结构完全相同。区别是每次修改完 Board,紧接着 await _animate_xxx(),让画面追上数据。

await 到底做了什么?执行到 await 某个信号 时,当前函数被挂起,控制权立刻还给调用者;等信号发出时,函数从挂起的地方接着往下执行,局部变量(chain、extra、prefer)都还在。await _animate_swap(a, b) 的意思是等那个函数执行完;因为它内部也有 await,它本身也是一个"协程"。

所以 _on_press() 里调用 _play_turn(from, cell) 时没有写 await:_play_turn 运行到第一个 await 就返回了,_unhandled_input 立刻结束,游戏继续正常处理每一帧。回合的剩余部分会在之后的若干帧里,随着一个个动画结束,被逐段唤醒执行。

时间轴最上面的黄色底色是输入锁定的范围:从交换开始,到最后一轮下落结束,整整一个回合 _busy 都是 true。

为什么动画期间要锁住输入

假设不锁:玩家在下落动画播到一半时又点了两颗宝石。这时 Board 的数据已经是下落之后的状态,但画面上宝石还在半空中,玩家点到的"那颗宝石"和数据里那一格的宝石可能根本不是同一颗。更糟的是第二个 _play_turn 会和第一个同时运行:两个协程交替修改同一个 Board 和同一个 _gems 数组,第一个回合下一次醒来时,它以为的空格已经被第二个回合填上了。_gems 和 board.cells 一旦对不上,后面所有的动画都会错位。

所以 _play_turn 第一行就设 _busy = true,输入处理、提示、洗牌按钮都先检查它;回合结束时 _finish_turn() 再放开。有些三消游戏允许"预输入"(动画快结束时就能开始拖下一步),那是在这个基础上把输入先记下来,等解锁后再执行,原理不变。

注意(从 Godot 3 迁移):Godot 3 用 yield(对象, "信号名"),Godot 4 改成了 await 对象.信号名;yield() 在 4.x 里已经不存在了。另外,调用一个协程却不写 await 不会报错,函数会在第一个 await 处返回,后面的代码在之后才执行。如果你需要"等它做完再继续",就必须写 await,否则会出现"下一行已经执行了,动画还没开始"的问题。

5. Tween:并行动画和 await finished

Godot 4 的 Tween 不再是节点,而是用 create_tween() 创建的对象:创建后自动开始播放,播完自动释放。默认情况下,往 Tween 里加的每一步(tweener)依次执行;调用 set_parallel(true) 后,之后加的步骤同时执行。整个 Tween 全部结束时发出 finished 信号,这正好可以拿来 await。

交换动画:两颗宝石同时向对方的位置移动,一个并行 Tween 就够了:

main.gd第 328–339 行在工程中查看 ↗
## 交换动画:一个 Tween 同时移动两颗宝石(set_parallel),await finished 等它播完。
func _animate_swap(a: Vector2i, b: Vector2i) -> void:
	var ga: Gem = _gems[board.idx(a)]
	var gb: Gem = _gems[board.idx(b)]
	_gems[board.idx(a)] = gb               # 表现层的数组也跟着换,保持和 board 一一对应
	_gems[board.idx(b)] = ga
	ga.z_index = 1                         # 被拖动的那颗画在上面
	var tw := create_tween().set_parallel(true).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
	tw.tween_property(ga, "position", view.cell_to_pixel(b), swap_time)
	tw.tween_property(gb, "position", view.cell_to_pixel(a), swap_time)
	await tw.finished
	ga.z_index = 0

注意 _gems 数组在动画开始前就交换了。表现层的数组要始终和 Board 保持一致,节点的位置只是在"追赶"数组。这样即使中途有别的代码需要查"第 (3, 4) 格是哪个节点",得到的也是正确答案。

消除动画要处理很多颗宝石,每颗还要"先放大,再缩小淡出"两段。仍然用一个并行 Tween,第二段用 set_delay() 推迟开始:

main.gd第 344–371 行一个并行 Tween 装下所有宝石的动画;set_delay 表达先后在工程中查看 ↗
## 消除动画:所有要消的宝石放进同一个并行 Tween。每颗先放大再缩小淡出,
## 第二段用 set_delay 推迟开始,这样一个 Tween 里就能表达"先后"。
func _animate_clear(res: Dictionary) -> void:
	var tw := create_tween().set_parallel(true)
	tw.tween_interval(clear_time)          # 保证 Tween 里至少有一步(空 Tween 会报错)
	var dying: Array[Gem] = []
	for e: Dictionary in res.cleared:
		var i := board.idx(e.cell)
		var g: Gem = _gems[i]
		_gems[i] = null
		if g == null:
			continue
		dying.append(g)
		fx.burst(g.position, Gem.color_of(e.kind), view.cell_size)
		tw.tween_property(g, "scale", Vector2(1.25, 1.25), clear_time * 0.35).set_trans(Tween.TRANS_SINE)
		tw.tween_property(g, "scale", Vector2.ZERO, clear_time * 0.65).set_delay(clear_time * 0.35) \
				.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN)
		tw.tween_property(g, "modulate:a", 0.0, clear_time * 0.65).set_delay(clear_time * 0.35)
	for t: Dictionary in res.triggered:
		_special_fx(t.cell, t.special)
	for s: Dictionary in res.spawned:       # 新生成的特殊宝石:原地变身 + 弹一下
		var g: Gem = _gems[board.idx(s.cell)]
		g.set_special(s.kind, s.special)
		g.scale = Vector2(1.6, 1.6)
		tw.tween_property(g, "scale", Vector2.ONE, clear_time * 1.2).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	await tw.finished
	for g in dying:
		g.queue_free()

为什么不给每颗宝石各建一个 Tween?那样要等"最后一个"结束,就得自己数有多少个 Tween、每结束一个减一。把它们放进同一个并行 Tween,finished 天然就是"全部结束"。

下落动画同理,已有宝石和新宝石放进同一个并行 Tween:

main.gd第 392–421 行落 n 格用时 fall_time·√n在工程中查看 ↗
## 下落动画:已有宝石往下掉,新宝石从棋盘上方 drop 格处掉进来,全部并行。
## 落 n 格用时 fall_time·√n:自由落体 h = ½gt²,t 和 √h 成正比,远的掉得久一点但不会久太多。
func _animate_fall(moves: Array[Dictionary], spawns: Array[Dictionary]) -> void:
	var tw := create_tween().set_parallel(true)
	tw.tween_interval(0.01)
	for m in moves:
		var g: Gem = _gems[board.idx(m.from)]
		_gems[board.idx(m.from)] = null
		_gems[board.idx(m.to)] = g
		_fall(tw, g, m.to, m.to.y - m.from.y)
	for s in spawns:
		var g := _make_gem(s.cell)
		g.position = view.cell_to_pixel(s.cell - Vector2i(0, s.drop))
		_fall(tw, g, s.cell, s.drop)
	await tw.finished


func _fall(tw: Tween, g: Gem, to: Vector2i, cells: int) -> PropertyTweener:
	var t := fall_time * sqrt(float(cells))
	var pt := tw.tween_property(g, "position", view.cell_to_pixel(to), t)
	match fall_ease:
		"BOUNCE":
			pt.set_trans(Tween.TRANS_BOUNCE).set_ease(Tween.EASE_OUT)
		"BACK":
			pt.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
		"QUAD":
			pt.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
		_:
			pt.set_trans(Tween.TRANS_LINEAR)
	return pt

为什么时长是 √n?自由落体的位移 h = ½gt²,所以 t = √(2h/g),时间和距离的平方根成正比。如果每格用时固定(时长和 n 成正比),落 8 格的宝石要等落 1 格的 8 倍时间,一列长距离下落会拖很久;用 √n,落 8 格只要 2.8 倍。再配上缓动曲线,就有"加速落下、落地弹一下"的感觉:

缓动由两个参数决定:set_trans() 选曲线的形状(QUAD、BACK、BOUNCE……),set_ease() 选用曲线的哪一端:EASE_IN 开始慢、后面快,EASE_OUT 开始快、后面慢。BOUNCE 配 EASE_OUT 是"落地后弹几下",配 EASE_IN 就成了"起跳前抖几下",用在下落上会很奇怪。

动手试试:在上面的示例里把"下落缓动"依次切换成 BACK、QUAD、LINEAR,各走几步。LINEAR 最"机械";QUAD(EASE_IN)像真实的重力,但落地时没有缓冲,会显得很硬;BOUNCE 和 BACK 的落地都有一个回弹,是大多数三消游戏的选择。再把"消除时长"拖到 0.8,能清楚看到每颗宝石先放大一下再缩小。

注意:有两种情况 await tween.finished 会永远等不到。一是 Tween 里一个步骤都没有:Godot 会报错 "Tween without commands" 并直接作废这个 Tween。代码里每个并行 Tween 都先加一个 tween_interval(),保证即使这一轮没有宝石要动,Tween 也是有效的。二是 Tween 被 kill(),或者创建它的节点被释放了(create_tween() 创建的 Tween 绑定在调用它的节点上),这两种情况都不会发出 finished,等待它的协程就一直挂着。

第二种情况直接影响了"改棋盘参数"的写法。玩家在连锁播到一半时把棋盘宽从 8 拖到 6,如果立刻删掉所有宝石节点重建,正在 await 的回合醒来后会去操作已经释放的节点。示例的做法是:动画进行中只记一个标记,等回合在下一个安全点退出后再重建:

main.gd第 117–125 行在工程中查看 ↗
## 改了棋盘大小、颜色数、种子:要整个重建。动画播到一半时不能直接删宝石节点
## (正在 await 的协程醒来后会去操作已经释放的节点),所以先记下来,等回合结束再做。
func _request_rebuild(_force := false) -> void:
	if not _started:
		return
	if _busy:
		_rebuild_pending = true
	else:
		_new_game()

_play_turn 的循环条件 while not _rebuild_pending 和每次 await 之后的检查,就是这个"安全点"。

6. 特殊宝石和链式触发

上一章的 classify() 决定了生成什么特殊宝石。生成的位置优先是玩家刚拖过去的那一格,其次是 L/T 的拐点,最后是这一段的中间。特殊宝石被消除时会扩大消除范围:

形状生成被消除时
横 4 连 / 竖 4 连直线炸弹(H / V)清掉整行 / 整列
L、T、十字范围炸弹(B)清掉周围 3×3
5 连同色炸弹(C)和谁交换就清掉所有那个颜色;被别的炸弹波及时,清掉场上最多的颜色

难点在于链式触发:一个直线炸弹清掉的那一行里,可能还有一个范围炸弹,它又会清掉周围 3×3,里面可能还有……这是一个典型的广度优先搜索(BFS):

board.gd第 235–280 行to_clear 是集合,queue 是待处理的队列在工程中查看 ↗
## 执行一轮消除。groups 来自 find_matches();prefer 是玩家刚交换的两个格子,
## 新的特殊宝石优先生成在那里;extra 是额外要清掉的格子(同色炸弹交换时用)。
## 返回 {cleared: [{cell, kind, special}], spawned: [{cell, kind, special}], triggered: [{cell, special}]}
func clear_matches(groups: Array[Dictionary], prefer: Array[Vector2i] = [], extra: Array[Vector2i] = []) -> Dictionary:
	var to_clear := {}       # 集合:要清掉的格子
	var protect := {}        # 这一轮新生成特殊宝石的格子,不清
	var spawned: Array[Dictionary] = []
	for g in groups:
		to_clear.merge(g.cells)
		var sp := classify(g) if specials_enabled else Special.NONE
		if sp != Special.NONE:
			var at := _spawn_cell(g, prefer)
			if at != NO_CELL and not protect.has(at):
				protect[at] = true
				spawned.append({"cell": at, "kind": RAINBOW if sp == Special.COLOR else g.kind, "special": sp})
	for c in extra:
		to_clear[c] = true
	for c in protect:
		to_clear.erase(c)

	# 链式触发:被清掉的格子里有特殊宝石,就把它的作用范围也加进来,
	# 新加进来的格子里如果又有特殊宝石,会在队列后面继续触发(广度优先)。
	var queue: Array = to_clear.keys()
	var triggered: Array[Dictionary] = []
	var head := 0
	while head < queue.size():
		var c: Vector2i = queue[head]
		head += 1
		var sp := special_at(c)
		if sp == Special.NONE:
			continue
		triggered.append({"cell": c, "special": sp})
		for t in effect_area(c, sp):
			if not to_clear.has(t) and not protect.has(t):
				to_clear[t] = true
				queue.append(t)

	var cleared: Array[Dictionary] = []
	for c: Vector2i in to_clear:
		cleared.append({"cell": c, "kind": kind_at(c), "special": special_at(c)})
		cells[idx(c)] = EMPTY
		special[idx(c)] = Special.NONE
	for s in spawned:
		cells[idx(s.cell)] = s.kind
		special[idx(s.cell)] = s.special
	return {"cleared": cleared, "spawned": spawned, "triggered": triggered}

先把所有匹配组的格子放进集合 to_clear,同时把它们放进队列。然后逐个取出队列里的格子:如果它有特殊宝石,就把它的作用范围(effect_area())里还不在集合里的格子加进集合,也加到队尾。队列处理完,集合里就是最终要清掉的所有格子。

"还不在集合里"这个检查很关键。两个横向直线炸弹在同一行时,第一个会把第二个加进来,第二个触发时又会覆盖到第一个。没有这个检查,它们会互相把对方加回队列,永远停不下来。有了集合,每个格子最多入队一次,整个过程最多处理 W·H 个格子。

board.gd第 285–351 行生成位置、作用范围、同色炸弹在工程中查看 ↗
## 特殊宝石生成在哪:玩家刚动过的格子 > L/T 的拐点 > 段的中间。已有特殊宝石的格子不用。
func _spawn_cell(g: Dictionary, prefer: Array[Vector2i]) -> Vector2i:
	var keys: Array = g.cells.keys()
	var candidates: Array = []
	candidates.append_array(prefer)
	candidates.append(g.corner)
	candidates.append(keys[keys.size() / 2])
	candidates.append_array(keys)
	for c in candidates:
		if g.cells.has(c) and special_at(c) == Special.NONE:
			return c
	return NO_CELL


## 特殊宝石被消除时影响的格子。
func effect_area(c: Vector2i, sp: int) -> Array[Vector2i]:
	var out: Array[Vector2i] = []
	match sp:
		Special.LINE_H:
			for x in width:
				out.append(Vector2i(x, c.y))
		Special.LINE_V:
			for y in height:
				out.append(Vector2i(c.x, y))
		Special.BOMB:
			for dy in range(-1, 2):
				for dx in range(-1, 2):
					if in_bounds(c + Vector2i(dx, dy)):
						out.append(c + Vector2i(dx, dy))
		Special.COLOR:
			var k := most_common_kind()
			for i in cells.size():
				if cells[i] == k:
					out.append(cell_of(i))
	return out


func most_common_kind() -> int:
	var counts := {}
	for k in cells:
		if _matchable(k):
			counts[k] = counts.get(k, 0) + 1
	var best := 0
	for k in counts:
		if counts[k] > counts.get(best, 0):
			best = k
	return best


## 玩家把同色炸弹和别的宝石交换:返回要清掉的格子(炸弹本身 + 所有和对方同色的宝石)。
## 两个同色炸弹互换:清空整个棋盘。调用前 a、b 已经交换过。
func activate_color_bomb(a: Vector2i, b: Vector2i) -> Array[Vector2i]:
	var out: Array[Vector2i] = []
	var ka := kind_at(a)
	var kb := kind_at(b)
	special[idx(a)] = Special.NONE if ka == RAINBOW else special[idx(a)]   # 引信用掉了,别在链式触发里再炸一次
	special[idx(b)] = Special.NONE if kb == RAINBOW else special[idx(b)]
	if ka == RAINBOW and kb == RAINBOW:
		for i in cells.size():
			out.append(cell_of(i))
		return out
	var target := kb if ka == RAINBOW else ka
	out.append(a if ka == RAINBOW else b)
	for i in cells.size():
		if cells[i] == target:
			out.append(cell_of(i))
	return out

同色炸弹的交换是唯一一种"不需要三连也合法"的交换。activate_color_bomb() 返回要清掉的格子,并把炸弹自己的特殊类型清掉("引信用掉了"),否则它在 BFS 里会再触发一次。返回的格子通过 clear_matches() 的 extra 参数传进去,和匹配组一起处理。

动手试试:在下面的示例里把"宝石种类"调到 4、打开"自动演示",特殊宝石会频繁出现:带白色条纹的是直线炸弹,外圈有光环的是范围炸弹,深色带彩色圆点的是同色炸弹。触发时会有横贯棋盘的光束或扩散的圆环。关掉"特殊宝石"开关再看,就只剩普通的三连了。

clear_matches() 的 BFS 里,如果去掉 not to_clear.has(t) 这个判断,会发生什么?

  1. 特殊宝石不会触发
  2. 只是会多算一些分数
  3. 两个能互相覆盖的炸弹会反复把对方加入队列,循环停不下来
  4. 没有影响,Dictionary 本来就会去重

Dictionary 确实会去重,to_clear[t] = true 写多少次都只有一个键;但 queue.append(t) 每次都会执行,队列会无限增长。判断"已经在集合里就不再入队",才能保证每个格子只处理一次。

7. 死局和洗牌

每个回合结束都要检查 find_valid_moves()。如果一步都没有,就是死局,必须洗牌。洗牌在逻辑层用 Fisher–Yates 算法生成一个随机排列:从最后一个位置开始,每个位置和它前面(含自己)随机的一个位置交换。这样得到的每种排列概率相等。

board.gd第 399–421 行在工程中查看 ↗
## 死局洗牌:把所有宝石打乱(Fisher–Yates),直到"没有三连并且至少有一步可走"。
## 返回排列 perm:新下标 i 上的宝石来自旧下标 perm[i](动画用它把宝石飞到新位置)。
## 洗 200 次都不行(颜色太少、棋盘太小)就重新生成,返回空数组。
func shuffle() -> PackedInt32Array:
	var old_cells := cells.duplicate()
	var old_special := special.duplicate()
	var perm := PackedInt32Array()
	perm.resize(cells.size())
	for attempt in 200:
		for i in perm.size():
			perm[i] = i
		for i in range(perm.size() - 1, 0, -1):
			var j := rng.randi_range(0, i)
			var t := perm[i]
			perm[i] = perm[j]
			perm[j] = t
		for i in perm.size():
			cells[i] = old_cells[perm[i]]
			special[i] = old_special[perm[i]]
		if find_matches().is_empty() and not find_valid_moves().is_empty():
			return perm
	fill_without_matches()
	return PackedInt32Array()

洗出来的结果还要满足两个条件:没有三连(否则洗完就自动消除了),并且至少有一步能走。不满足就再洗一次。棋盘很小、颜色很多时,可能洗很多次都不满足,这时退回到"重新生成"。

逻辑层返回的 perm 告诉表现层"新的第 i 格上的宝石原来在第 perm[i] 格",表现层据此把每颗宝石飞到新位置:

main.gd第 459–480 行在工程中查看 ↗
## 死局:没有任何一步能形成消除。逻辑层负责洗出一个"无三连、有步可走"的排列,
## 表现层按排列 perm 把每颗宝石飞到新位置。
func _shuffle_board(msg: String) -> void:
	_set_hint([])
	hud.toast(msg)
	var old := _gems.duplicate()
	var perm := board.shuffle()
	var tw := create_tween().set_parallel(true)
	tw.tween_interval(0.01)
	if perm.is_empty():                    # 洗不出来,逻辑层重新生成了:宝石全部重建
		for g in old:
			if g:
				g.queue_free()
		for i in _gems.size():
			_make_gem(board.cell_of(i)).scale = Vector2.ZERO
			tw.tween_property(_gems[i], "scale", Vector2.ONE, 0.3).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	else:
		for i in perm.size():
			_gems[i] = old[perm[i]]
			tw.tween_property(_gems[i], "position", view.cell_to_pixel(board.cell_of(i)), 0.45) \
					.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN_OUT).set_delay(_fx_rng.randf() * 0.12)
	await tw.finished

死局有多常见?关掉特殊宝石随机走 1000 步,7 种颜色时有 15 步之后需要洗牌,6 种颜色时只有 2 步,5 种及以下一次都没有。所以在默认的 6 种颜色下,自动洗牌很少看到,可以用示例里的"洗牌"按钮手动触发。

8. 分数、连锁倍数和关卡目标

计分规则要回答一个问题:你想鼓励玩家做什么?示例的规则是:

main.gd第 436–454 行在工程中查看 ↗
## 计分:每颗 10 分;一组超过 3 颗,每多一颗 +20;每触发一个特殊宝石 +30。
## 整轮得分再乘以连锁数:第 1 轮 ×1,第 2 轮 ×2……连锁越深越值钱。
func _add_score(res: Dictionary, groups: Array[Dictionary]) -> void:
	var base: int = res.cleared.size() * 10 + res.triggered.size() * 30
	for g in groups:
		base += maxi(0, g.cells.size() - 3) * 20
	var gained := base * chain
	score += gained
	max_chain = maxi(max_chain, chain)
	var center := Vector2.ZERO
	for e: Dictionary in res.cleared:
		center += view.cell_to_pixel(e.cell)
	center /= maxf(1.0, res.cleared.size())
	var col := Color("#fff2a8") if chain < 2 else Color("#ff9ad5")
	fx.float_text(center, "+%d" % gained, col, int(view.cell_size * (0.42 + 0.06 * mini(chain, 5))))
	if chain >= 2:
		hud.show_chain(chain)
		_shake = maxf(_shake, shake_strength * (0.5 + 0.25 * mini(chain, 4)))
	_refresh_hud()
  • 每颗 10 分是基础分。
  • 一组超过 3 颗,每多一颗加 20:鼓励玩家去找 4 连、5 连,而不是看到三连就消。
  • 每触发一个特殊宝石加 30:鼓励玩家去用特殊宝石,而不是囤着。
  • 整轮得分乘以连锁数:第 1 轮 ×1、第 2 轮 ×2……一次三轮的连锁,第三轮的 3 颗宝石值 90 分,是第一轮的三倍。

倍数为什么用线性(×1、×2、×3)而不是指数(×1、×2、×4、×8)?看上面那张统计表:4 种颜色时最长连锁有 14 轮,指数倍数下第 14 轮是 ×8192,一步的分数会远远超过整个关卡的目标,目标分数就失去了意义。线性倍数让连锁有明显的奖励,又不会让一次运气盖过整局的操作。

关卡目标是"在限定步数内达到目标分数",星级按目标的倍数算:

hud.gd第 151–157 行在工程中查看 ↗
## 星级:达到目标 1 星,1.5 倍 2 星,2 倍 3 星。进度条的满格是 2 倍目标。
static func stars_for(score: int, target: int) -> int:
	if score >= target * 2:
		return 3
	if score >= roundi(target * 1.5):
		return 2
	return 1 if score >= target else 0

步数用完就结算:显示星级和分数,以及"再来一局"按钮。按钮在 HUD 的 CanvasLayer 上,点击被按钮处理掉,不会再传到棋盘的 _unhandled_input,这正是上一章选择 _unhandled_input 的原因。

动手试试:把"步数限制"调到 5,走完 5 步看结算面板。再把"目标分数"调低到 500,结算面板会变成三颗星(目标分数在结算后改也会立刻重新判断)。最后把"特殊宝石"关掉、步数调回 20,比较同样 20 步能拿到多少分,感受一下特殊宝石在分数里占了多大比重。

一步造成了两轮消除:第 1 轮消了一组 4 连(4 颗,生成直线炸弹的那颗不消,实际清掉 3 颗),第 2 轮消了一组普通三连。按示例的规则,这一步共得多少分?

  1. 60
  2. 70
  3. 100
  4. 110

第 1 轮:清掉 3 颗 × 10 = 30,这一组 4 颗比 3 多 1 颗 +20,共 50,× 连锁 1 = 50。第 2 轮:3 × 10 = 30,× 连锁 2 = 60。合计 110。注意"多出的颗数"按组的大小算(4 颗),而基础分按实际清掉的格子算(3 颗)。

小结

  • 重力压缩用读、写两个指针,每列一遍 O(H);逻辑层返回移动列表,表现层按它移动节点。
  • 补充新宝石不避开三连,新宝石从上方 drop 格处一起落下;宝石种类越少,连锁越频繁。
  • 先写同步的 play_move() 作为规则的标准答案,用随机操作 + 不变量检查测试它;带动画的 _play_turn() 结构和它一一对应,只是在每一步后面 await 动画。
  • 回合进行中用 _busy 锁住输入;改棋盘参数时只记标记,等回合在安全点退出后再重建。
  • 多个宝石的动画放进一个 set_parallel(true) 的 Tween,用 set_delay 表达先后,await tween.finished 等全部结束;空 Tween 和被杀掉的 Tween 不会发出 finished。
  • 特殊宝石的链式触发是 BFS:集合保证每个格子只处理一次。
  • 死局用 Fisher–Yates 洗牌,直到"无三连且有步可走";连锁倍数用线性,避免一次运气压过整局。