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

三消(上):网格、交换与匹配算法

把棋盘拆成"纯数据的 Board"和"只管显示的 Gem",然后依次实现网格与像素坐标的换算、点击和拖动交换、匹配扫描、L/T 形合并、开局不带三连的生成方法和可行步检测。

三消游戏的规则一句话就能说完:交换相邻的两颗宝石,横着或竖着连成 3 颗以上同色的就消掉。但写起来,"连成"怎么判断、L 形算几颗、开局怎么保证不自带三连、怎么知道还有没有步可走,每一项都是一个小算法。这一章只处理静态的棋盘:数据怎么存、玩家怎么操作、怎么找出匹配。下一章再讲消除之后的下落、连锁和动画。

先玩一下本章的示例。打开"显示逻辑网格",每格左上角会显示 Board 数组里的颜色编号,后面讲算法时可以对照着看。

动手试试:把"棋盘宽"拖到 5,再拖回 8。棋盘会立刻重建,而且两次 8×8 的开局一模一样,因为"随机种子"没变。把种子改成 0,每次重建都会得到不同的棋盘。再把"宝石种类"拖到 3,看看开局有多少步可走(右侧实时数据里的"可行步数")。

1. 逻辑层和表现层分开

新手写三消,常见的做法是每颗宝石一个节点,判断匹配时去读节点的 position、比较节点的颜色属性。这样很快会遇到三个问题:

  • 动画期间数据不可靠。宝石正在往下掉的时候,它的 position 还在两格之间,这时候它"属于"哪一格?
  • 算法没法单独测。想验证"L 形能不能识别",得先跑起整个场景、摆好节点。
  • 同一件事要等。"交换后有没有三连"本来是一个立刻就能算出来的问题,却要等交换动画播完、节点到位之后才能判断。

所以示例把代码分成三层:

文件职责依赖节点吗
board.gd(class Board)棋盘数据和全部算法:扫描、合并、可行步、下落、补充、洗牌不依赖,继承 RefCounted
gem.gd(class Gem)一颗宝石的绘制和小动画,不知道自己在哪一格是 Node2D
main.gd"导演":接收输入,调用 Board 算出结果,再让 Gem 把结果演出来是场景根节点

Board 的全部状态就是两个整数数组:

board.gd第 7–31 行棋盘就是两个 PackedInt32Array在工程中查看 ↗
const EMPTY := -1             ## 空格(刚被消掉,还没补上)
const RAINBOW := 99           ## 同色炸弹没有颜色,用一个不会参与匹配的值表示
const NO_CELL := Vector2i(-1, -1)

enum Special { NONE, LINE_H, LINE_V, BOMB, COLOR }

var width := 8
var height := 8
var kinds := 6                ## 宝石颜色种类数
var specials_enabled := true
## 一维数组存二维棋盘:格子 (x, y) 的下标是 y * width + x
var cells := PackedInt32Array()     # 每格的颜色 0..kinds-1,EMPTY 或 RAINBOW
var special := PackedInt32Array()   # 每格的特殊类型 Special
var rng := RandomNumberGenerator.new()   # 自己的随机数发生器:种子相同,整局就完全相同


func _init(w := 8, h := 8, k := 6, seed_value := 1) -> void:
	width = w
	height = h
	kinds = k
	rng.seed = seed_value
	cells.resize(w * h)
	cells.fill(EMPTY)
	special.resize(w * h)
	special.fill(Special.NONE)

二维棋盘用一维数组存,格子 (x, y) 的下标是 y * width + x。这种"按行展开"的存法在图像、Tilemap 里都很常见。PackedInt32Array 在内存里是一整块连续的 32 位整数,比 Array(每个元素是一个 Variant)省空间,复制也快,后面做洗牌、做快照都要用到 duplicate()。

Board 继承 RefCounted 而不是 Node:它不需要进场景树,没有 _process,没人引用它时会自动释放。这让它可以在一个完全没有场景的脚本里被 new() 出来测试:

tests/board_test.gd第 35–52 行无界面测试:用字符串摆一个 L 形,检查合并结果在工程中查看 ↗
func _test_scan_and_merge() -> void:
	var b := _board_from([
		"11123",
		"45163",
		"23145",
		"54321",
	])
	var groups := b.find_matches()
	_check(groups.size() == 1, "L 形应合并成 1 组,实际 %d 组" % groups.size())
	if groups.size() == 1:
		var g: Dictionary = groups[0]
		_check(g.cells.size() == 5, "L 形 5 格(拐点只算一次),实际 %d" % g.cells.size())
		_check(g.corner == Vector2i(2, 0), "拐点应是 (2,0),实际 %s" % g.corner)
		_check(b.classify(g) == Board.Special.BOMB, "L 形应生成范围炸弹")
	var line5 := _board_from(["22222", "13131", "31313"])
	_check(b.classify(line5.find_matches()[0]) == Board.Special.COLOR, "5 连应生成同色炸弹")
	var line4 := _board_from(["1", "1", "1", "1", "2"])
	_check(line4.classify(line4.find_matches()[0]) == Board.Special.LINE_V, "竖 4 连应生成竖向直线炸弹")

这个测试用 godot --headless -s res://tests/board_test.gd 运行,不开窗口,一秒钟就跑完。下一章还会用同一个脚本做 1000 次随机交换的压力测试。

注意:逻辑层不能反过来引用表现层。Board 里不应该出现 Gem、Node、Tween,也不要在 Board 里存宝石节点的引用。表现层(main.gd)另外维护一个和 cells 一一对应的数组 _gems,每次逻辑层的格子变了,表现层的数组跟着做同样的变化。只要这条线不交叉,以后把动画全换掉、或者把棋盘搬到服务器上验证,逻辑层都不用动。

2. 网格坐标和像素坐标

逻辑层只认格子坐标 Vector2i(x, y),屏幕上只有像素。两者的换算只有两个公式。设格子边长为 s,棋盘左上角在 origin:

  • 格子 → 像素(格子中心):pixel = origin + (cell + 0.5) × s
  • 像素 → 格子:cell = floor((pixel − origin) ÷ s)

示例里 origin 不出现在公式里:棋盘节点 BoardView 本身就放在 origin 的位置,宝石是它的子节点,所以子节点的坐标天然就是"相对棋盘左上角"的。换算函数只剩下乘除:

board_view.gd第 23–30 行在工程中查看 ↗
## 格子 → 像素:格子中心点。
func cell_to_pixel(c: Vector2i) -> Vector2:
	return (Vector2(c) + Vector2(0.5, 0.5)) * cell_size


## 像素 → 格子:除以格子大小再向下取整。负数也要向下取整,所以用 floori 而不是 int()。
func pixel_to_cell(p: Vector2) -> Vector2i:
	return Vector2i(floori(p.x / cell_size), floori(p.y / cell_size))

注意:像素换格子要用 floori(),不要用 int()。int() 是向零截断:在棋盘左边 10 像素外点击,-10 / 60.0 = -0.17,int() 得到 0,程序会以为你点了第 0 列;floori() 得到 −1,in_bounds() 才能把它排除掉。

格子边长 s 不是写死的。main.gd 的 _layout() 根据窗口大小和棋盘的行列数算出能放下的最大格子,所以改棋盘宽高、或者在手机上竖屏打开,棋盘都会重新排版。项目设置里的拉伸模式是 canvas_items + expand:横屏时逻辑分辨率至少是 960×540,竖屏时高度会变大,这时 HUD 会挪到棋盘上方。

3. 输入:点击和拖动

三消有两种交换手势,示例两种都支持:

  1. 点两下:先点一颗(出现选中框),再点它上下左右相邻的一颗。
  2. 拖动:按住一颗往某个方向拖,拖过一定距离就和那个方向的邻居交换。手机上就是滑动。
main.gd第 222–265 行输入处理:选中、第二次点击、拖动方向在工程中查看 ↗
## 用 _unhandled_input 而不是 _input:HUD 上的按钮先处理点击,没被界面吃掉的才轮到棋盘。
## 触屏在 Web 和手机上默认会被模拟成鼠标事件(emulate_mouse_from_touch),所以只处理鼠标即可。
func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
		var local := view.make_input_local(event) as InputEventMouseButton   # 换算到棋盘坐标
		if event.pressed:
			_on_press(view.pixel_to_cell(local.position), local.position)
		else:
			_drag_from = Board.NO_CELL
	elif event is InputEventMouseMotion and _drag_from != Board.NO_CELL:
		var local := view.make_input_local(event) as InputEventMouseMotion
		_on_drag(local.position)


func _on_press(cell: Vector2i, pos: Vector2) -> void:
	if _busy or _game_over or not board.in_bounds(cell):
		_selected = Board.NO_CELL
		overlay.selected = _selected
		return
	_idle = 0.0
	if _selected != Board.NO_CELL and board.is_adjacent(_selected, cell):
		var from := _selected      # 第二次点击:和已选中的相邻格交换
		_selected = Board.NO_CELL
		overlay.selected = _selected
		_play_turn(from, cell)
		return
	_selected = cell               # 第一次点击:选中,同时准备拖动
	overlay.selected = cell
	_drag_from = cell
	_drag_start = pos


## 拖动:位移超过阈值后,按 |dx| 和 |dy| 谁大决定方向,只取上下左右四个方向之一。
func _on_drag(pos: Vector2) -> void:
	var d := pos - _drag_start
	if d.length() < view.cell_size * drag_threshold:
		return
	var dir := Vector2i(signi(roundi(d.x)), 0) if absf(d.x) > absf(d.y) else Vector2i(0, signi(roundi(d.y)))
	var from := _drag_from
	_drag_from = Board.NO_CELL
	if board.in_bounds(from + dir) and not _busy:
		_selected = Board.NO_CELL
		overlay.selected = _selected
		_play_turn(from, from + dir)

几个细节:

  • 为什么是 _unhandled_input。Godot 的输入事件先经过 _input,再交给界面控件(Control 的 _gui_input),没被界面吃掉的才到 _unhandled_input。结算面板上的"再来一局"按钮会先处理点击;如果棋盘用的是 _input,点按钮的同时也会选中按钮下面的宝石。
  • make_input_local() 把事件的坐标换算到某个 CanvasItem 的本地坐标。棋盘震屏时 BoardView 的位置在抖,用它换算可以保证点到的格子和看到的一致。
  • 触屏。项目设置 input_devices/pointing/emulate_mouse_from_touch 默认是开的:手指按下、移动、抬起会被同时转换成鼠标事件。所以只写鼠标的处理,手机浏览器上也能玩。如果需要多指操作,再单独处理 InputEventScreenTouch。
  • 拖动方向只取上下左右之一:比较位移的 |dx| 和 |dy|,谁大就取谁的方向。斜着拖 45° 附近时,偏一点点就会决定方向,这对三消来说可以接受。
  • 拖动阈值用格子宽的比例(drag_threshold × cell_size)而不是固定像素。格子在手机上可能只有 30 像素宽,在桌面上有 80 像素,用比例两边手感一致。

动手试试:把"拖动阈值"调到 0.1,轻轻按一下宝石、手指稍微抖一下就会触发交换;调到 1.0,要拖满整整一格才算数。0.3~0.4 之间对大多数人比较合适:手抖不会误触,又不用拖很远。

玩家按住格子 (4, 4) 往右上方拖,位移是 dx = 18、dy = −25(单位:像素,阈值 15 像素)。会和哪一格交换?

  1. (5, 4)
  2. (5, 3)
  3. (4, 3)
  4. 不交换,因为方向是斜的

|dy| = 25 大于 |dx| = 18,所以取竖直方向;dy 是负数,即向上(Godot 的 y 轴向下为正)。目标是 (4, 3)。斜方向永远不会出现:代码只会产生四个方向之一。

4. 交换合法性

一次交换要同时满足两个条件才算"合法":

  1. 相邻:曼哈顿距离 |ax − bx| + |ay − by| = 1。对角线距离是 2,不算相邻。
  2. 交换后能形成至少一组三连。不满足的话要换回去(画面上会看到宝石过去又回来,下一章讲这段动画)。

第 2 条可以偷懒:交换只改变了 a、b 两格,其他格子原来没有三连,现在也不会凭空有。所以不用扫描全盘,只要看经过 a 的那一横一竖和经过 b 的那一横一竖:从这一格往左、往右数同色的个数,加上自己有没有到 3;上下同理。

board.gd第 186–216 行换过去、看一眼、换回来在工程中查看 ↗
## 判断交换 a、b 之后能不能形成消除(不真的改棋盘:换过去、看一眼、换回来)。
func is_valid_swap(a: Vector2i, b: Vector2i) -> bool:
	if not (in_bounds(a) and in_bounds(b) and is_adjacent(a, b)):
		return false
	if kind_at(a) == RAINBOW or kind_at(b) == RAINBOW:
		return true                 # 同色炸弹和谁换都能炸
	if kind_at(a) == kind_at(b):
		return false
	swap(a, b)
	var ok := _match_through(a) or _match_through(b)
	swap(a, b)
	return ok


## 只看经过 c 的那一横一竖:往两边数同色,加上自己 ≥ 3 就是三连。
func _match_through(c: Vector2i) -> bool:
	var k := kind_at(c)
	if not _matchable(k):
		return false
	var h := 1 + _count(c, Vector2i.LEFT, k) + _count(c, Vector2i.RIGHT, k)
	var v := 1 + _count(c, Vector2i.UP, k) + _count(c, Vector2i.DOWN, k)
	return h >= 3 or v >= 3


func _count(c: Vector2i, dir: Vector2i, k: int) -> int:
	var n := 0
	var p := c + dir
	while kind_at(p) == k:
		n += 1
		p += dir
	return n

同色炸弹(5 连生成的特殊宝石,颜色值是 RAINBOW)和任何宝石交换都合法,这条规则也写在这里。两颗同色的普通宝石交换等于没换,直接判不合法。

5. 匹配扫描

交换合法之后,要找出棋盘上所有的三连,才知道该消哪些格子。最直接的方法是游程扫描(run-length scan):对每一行,从左往右找"连续同色的一段",长度 ≥ 3 就记下来;每一列同理。

找一段同色用两个下标:i 是段的起点,j 从 i + 1 开始往后走,只要 cells[j] == cells[i] 就继续。j 停下时,[i, j) 就是一段同色,长度 j − i。然后直接让 i = j,从下一段的开头继续,不回头。

board.gd第 103–128 行横向、纵向共用一个扫描函数,用 step 区分方向在工程中查看 ↗
## 扫描所有横向、纵向长度 ≥ 3 的同色连续段(run)。
## 每一行、每一列各走一遍,每个格子被看两次,复杂度 O(W·H)。
func find_runs() -> Array[Dictionary]:
	var runs: Array[Dictionary] = []
	for y in height:
		_scan_line(Vector2i(0, y), Vector2i.RIGHT, width, true, runs)
	for x in width:
		_scan_line(Vector2i(x, 0), Vector2i.DOWN, height, false, runs)
	return runs


## 沿 step 方向走 length 格,用"段起点 + 段终点"两个下标找连续段。
func _scan_line(start: Vector2i, step: Vector2i, length: int, horizontal: bool, runs: Array[Dictionary]) -> void:
	var i := 0
	while i < length:
		var k := cells[idx(start + step * i)]
		var j := i + 1
		while j < length and cells[idx(start + step * j)] == k:
			j += 1
		# [i, j) 是一段同色,长度 j - i
		if j - i >= 3 and _matchable(k):
			var run: Array[Vector2i] = []
			for t in range(i, j):
				run.append(start + step * t)
			runs.append({"cells": run, "kind": k, "horizontal": horizontal})
		i = j    # 直接跳到下一段,不回头

复杂度。每一行里,j 从左走到右,每个格子最多被比较一次;i 只会跳到 j,不会回退。所以扫描一行是 O(W),所有行是 O(W·H);纵向再来一遍,也是 O(W·H)。合起来每个格子被看两次,总共 O(W·H)。8×8 的棋盘一百多次比较,每帧都跑也没有压力。

横向和纵向的扫描逻辑完全一样,区别只是"往哪走"。_scan_line 接收一个起点和一个步长向量 step:横向传 Vector2i.RIGHT,纵向传 Vector2i.DOWN,第 t 个格子就是 start + step * t。这样不需要写两遍几乎一样的循环,改一处另一处就不会忘。

说明:扫描时要跳过空格(EMPTY = -1)和同色炸弹(RAINBOW = 99)。_matchable() 负责这个判断。如果不跳过,下落过程中顶部连着三个空格也会被当成"三连"。

6. L 形和 T 形:用集合合并

扫描得到的是一段一段的直线。L 形是一条横段和一条竖段共用一个拐角格子,T 形和十字形也是这样。如果不合并,同一个拐角格子会被两段各算一次:消除时会重复计分,决定"生成什么特殊宝石"时也只能看到半个形状。

合并的思路:把每一组的格子放进一个集合里。新来一段时,检查它和已有的哪些组有共同的格子,把这些组全部并进来,再加入这一段自己的格子。GDScript 没有专门的 Set 类型,惯用的做法是用 Dictionary 当集合:键是 Vector2i,值随便填 true。has() 是 O(1),重复加入同一个键只会保留一个。

board.gd第 133–168 行和已有的组有交集就合并;同一格出现两次就是拐点在工程中查看 ↗
## 把共享格子的连续段合并成一组:L 形、T 形、十字形都是"一横一竖"共用一个格子。
## 组里的格子用 Dictionary 当集合存(键是 Vector2i,值随便),同一个格子只会出现一次。
## 返回 Array[Dictionary]:{cells: {Vector2i: true}, kind, h, v, longest, corner}
func find_matches() -> Array[Dictionary]:
	var groups: Array[Dictionary] = []
	for run in find_runs():
		var merged := {"cells": {}, "kind": run.kind, "h": 0, "v": 0, "longest": 0, "corner": NO_CELL}
		for g in groups.duplicate():
			if _shares_cell(g.cells, run.cells):
				_absorb(merged, g)
				groups.erase(g)
		for c: Vector2i in run.cells:
			if merged.cells.has(c):
				merged.corner = c      # 这个格子同时属于横段和竖段:L/T 的拐点
			merged.cells[c] = true
		merged.h += 1 if run.horizontal else 0
		merged.v += 0 if run.horizontal else 1
		merged.longest = maxi(merged.longest, run.cells.size())
		groups.append(merged)
	return groups


func _shares_cell(set_a: Dictionary, run_cells: Array[Vector2i]) -> bool:
	for c in run_cells:
		if set_a.has(c):
			return true
	return false


func _absorb(into: Dictionary, g: Dictionary) -> void:
	into.cells.merge(g.cells)
	into.h += g.h
	into.v += g.v
	into.longest = maxi(into.longest, g.longest)
	if g.corner != NO_CELL:
		into.corner = g.corner

一个 T 形由横 3 和竖 3 组成,两段一共 6 个格子位置,但拐点只算一次,合并后的组是 5 格。加入新一段时,如果某个格子已经在集合里,说明它同时属于横段和竖段,就是拐点 corner。生成特殊宝石时,L/T 形的炸弹会放在这里。

合并完,再按形状决定生成什么特殊宝石。优先级从高到低:

board.gd第 173–181 行在工程中查看 ↗
## 根据组的形状决定生成哪种特殊宝石。优先级:5 连 > L/T > 4 连。
func classify(g: Dictionary) -> int:
	if g.longest >= 5:
		return Special.COLOR
	if g.h > 0 and g.v > 0:
		return Special.BOMB
	if g.longest == 4:
		return Special.LINE_H if g.h > 0 else Special.LINE_V
	return Special.NONE

为什么 5 连优先于 L/T?一组里如果同时有一条 5 连和一条竖 3(比如横 5 加竖 3 的 T 形),它既满足"有横有竖",也满足"最长段 ≥ 5"。同色炸弹威力更大,也更难凑出来,所以让它优先。这类规则没有标准答案,不同的三消游戏有不同的取舍,关键是写成一个独立的函数,改规则时只动这一处。

注意:用 Array 存格子再 has() 判断也能做出来,但 Array.has() 要从头找到尾,是 O(n)。另外不要用 Vector2(浮点)当字典的键:Vector2(1, 2) 和计算出来的 Vector2(1.0000001, 2) 是两个不同的键。格子坐标一律用 Vector2i。

棋盘上有一个十字形:横 5(x=0..4, y=2)和竖 5(x=2, y=0..4)。合并后这一组有几个格子?会生成什么?

  1. 10 格,同色炸弹
  2. 9 格,同色炸弹
  3. 9 格,范围炸弹
  4. 10 格,范围炸弹

两段共用 (2, 2),所以是 5 + 5 − 1 = 9 格。最长段是 5,classify 先判断 longest >= 5,生成同色炸弹。

7. 开局不能自带三连

开局棋盘如果已经有三连,玩家还没动手就开始消除,这既不公平,也让"第一步"的设计失效。怎么生成一个没有三连的随机棋盘?

方法一:随机填满,有三连就整盘重来。写起来最简单,但要重试多少次?在 8×8 的棋盘上随机填色,用游戏里的 Board 实际试了 4000 次,没有三连的比例如下:

宝石种类34567
一次就没有三连的概率0.03%0.65%3.3%8.9%16.4%
平均要重试的次数(≈ 1 / 概率)约 3000约 150约 30约 11约 6

可以粗略估算一下:8×8 的棋盘上,横着连续三格的位置有 6×8 = 48 个,竖着也有 48 个,共 96 个"三格窗口"。每个窗口三格同色的概率是 1/k²(第二格和第三格都要和第一格同色)。k = 6 时期望有 96/36 ≈ 2.7 个三连,一个都没有的概率大约是 e−2.7 ≈ 7%,和实测的 8.9% 是同一个量级。种类越少,这个概率掉得越快。

方法二:逐格排除。按从左到右、从上到下的顺序填。填 (x, y) 的时候,只有它左边和上面的格子已经有颜色,右边和下面都还是空的。所以它能凑成三连只有两种可能:和左边两格连成横三,或者和上面两格连成竖三。只要左边两格同色,就把这个颜色排除;上面两格同色,也排除。最多排除 2 种颜色,只要种类 ≥ 3 就一定有得选。

board.gd第 77–98 行每格最多排除两种颜色在工程中查看 ↗
## 生成一个开局就没有三连的棋盘。按从左到右、从上到下的顺序填:
## 填 (x, y) 时只有左边两格和上面两格已经确定,所以只要避开
## "左边两格同色"和"上面两格同色"的那个颜色,就不可能凑成三连。
## 每格最多排除 2 种颜色,kinds ≥ 3 时一定有得选。
func fill_without_matches() -> void:
	for attempt in 100:
		for y in height:
			for x in width:
				var banned: Array[int] = []
				if x >= 2 and cells[idx(Vector2i(x - 1, y))] == cells[idx(Vector2i(x - 2, y))]:
					banned.append(cells[idx(Vector2i(x - 1, y))])
				if y >= 2 and cells[idx(Vector2i(x, y - 1))] == cells[idx(Vector2i(x, y - 2))]:
					banned.append(cells[idx(Vector2i(x, y - 1))])
				var choices: Array[int] = []
				for k in kinds:
					if k not in banned:
						choices.append(k)
				cells[idx(Vector2i(x, y))] = choices[rng.randi_range(0, choices.size() - 1)]
				special[idx(Vector2i(x, y))] = Special.NONE
		# 没有三连还不够:开局至少要有一步能走,否则重新生成
		if not find_valid_moves().is_empty():
			return

逐格排除一次就能填好,不需要重试。外面还套了一层循环,是因为"没有三连"不等于"能玩":理论上可能生成一个一步都走不了的棋盘(种类多、棋盘小时偶尔出现),所以填完还要检查可行步,没有就再生成一次。

随机种子。所有随机数都来自 Board 自己的 RandomNumberGenerator,种子在构造时传入。同一个种子,开局一样,之后每次补充的新宝石也一样。这在调试时非常有用:玩家报告"第 7 步出了 bug",你用同一个种子、同样的 7 步就能复现。示例里震屏、提示、自动演示用的是另一个随机数发生器 _fx_rng,不会打乱棋盘的随机序列。

注意:不要在逻辑层里用全局的 randi()、randf()。它们共用一个全局状态,任何地方(粒子、音效变化、别的脚本)多调用一次,棋盘的随机序列就整体错开,种子也就没用了。Godot 3 的 rand_range() 在 4.x 里改名为 randf_range(),RandomNumberGenerator 上的方法名一样。

8. 可行步检测

"还有没有步可走"决定两件事:没有就要洗牌(下一章),玩家发呆太久要提示哪一步能走。可行步的找法是把每一对相邻格子都试一遍:用前面的 is_valid_swap(),换过去、只看经过这两格的横竖线、再换回来。

board.gd第 221–230 行在工程中查看 ↗
## 所有可行的交换。每个格子只试"右"和"下"两个方向,避免同一对算两次。
func find_valid_moves() -> Array[Array]:
	var moves: Array[Array] = []
	for y in height:
		for x in width:
			var a := Vector2i(x, y)
			for d in [Vector2i.RIGHT, Vector2i.DOWN]:
				if is_valid_swap(a, a + d):
					moves.append([a, a + d])
	return moves

每个格子只试"右"和"下"两个方向。如果四个方向都试,(a 换 b) 和 (b 换 a) 会被算两次。W×H 的棋盘一共有 (W−1)·H + W·(H−1) 对相邻格子,8×8 是 112 对。

复杂度。每一对的检查只数经过两格的横竖线,通常只看几个格子,最坏情况(整行同色)要数 O(W + H) 个。总共 O(W·H) 对,所以是 O(W·H·(W+H)),一般情况下接近 O(W·H)。对比一下更"笨"的做法:每对都交换后跑一遍完整的 find_matches(),那是 O(W·H) 对 × O(W·H) 扫描 = O((W·H)²),8×8 时大约是不到一千次比较和一万多次比较的差别。

可行步的数量和宝石种类关系很大。下面是用逐格排除法生成 300 个 8×8 开局,平均的可行步数:

宝石种类34567
开局平均可行步41.127.319.014.310.6

种类越多,可行步越少,玩家要花更多时间找,也越容易走进死局。很多三消游戏用 5~6 种颜色,就是在"太简单"和"老是死局"之间取的折中。

有了可行步列表,提示功能就很简单:玩家一段时间没有操作,就从列表里挑一步,让那两颗宝石轻轻地反复缩放。

main.gd第 485–489 行在工程中查看 ↗
## 提示一步:从所有可行步里随机挑一个(用特效专用的随机数,不影响棋盘的随机序列)。
func _show_hint() -> void:
	var moves := board.find_valid_moves()
	if not moves.is_empty():
		_set_hint(moves[_fx_rng.randi_range(0, moves.size() - 1)])

动手试试:把"提示延迟"调到 1 秒,不要操作,一秒后会有两颗宝石开始跳动,中间有一个箭头指出交换方向。打开"显示逻辑网格"对照数字,确认这两格交换后确实能连成三个。再把"宝石种类"拖到 7,看实时数据里的"可行步数"降到多少。

逐格填色时,为什么只检查"左边两格"和"上面两格",不检查右边和下面?

  1. 按从左到右、从上到下的顺序填,右边和下面还没有颜色,不可能和它组成三连
  2. 右边和下面的格子会在下落时被替换,不用管
  3. 为了性能,检查四个方向太慢
  4. Godot 的数组只能向前访问

填 (x, y) 时,右边和下面的格子都还是空的。这一格只可能和"已经填好的"格子组成三连,也就是左边连续两格或上面连续两格。等以后填右边的格子时,会轮到那一格去检查它左边的两格(其中就包括现在这一格)。

小结

  • 逻辑层 Board 是 RefCounted,只有两个 PackedInt32Array 和一组算法,可以在无界面脚本里直接测试;表现层的 Gem 只负责画。
  • 格子 → 像素:(cell + 0.5) × s;像素 → 格子:floori(p / s),负数要向下取整。
  • 输入用 _unhandled_input,让界面先处理;触屏默认会被模拟成鼠标事件;拖动方向比较 |dx| 和 |dy|。
  • 匹配扫描用"段起点 i、段终点 j"一遍扫过,横竖各一遍,O(W·H);L/T 形用 Dictionary 当集合合并,重复出现的格子就是拐点。
  • 开局逐格排除"左边两格同色"和"上面两格同色"的颜色,不需要重试;所有随机数来自带种子的 RandomNumberGenerator。
  • 可行步只试"右"和"下",每对只检查经过两格的线,不用全盘重扫。

到这里,棋盘能生成、能交换、能找出要消的格子。下一章处理消除之后发生的一切:格子空了怎么往下掉、新宝石从哪来、连锁怎么循环,以及怎么用 await 和 Tween 把这些步骤一段一段地演出来。