三消(上):网格、交换与匹配算法
把棋盘拆成"纯数据的 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 的全部状态就是两个整数数组:
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() 出来测试:
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 的位置,宝石是它的子节点,所以子节点的坐标天然就是"相对棋盘左上角"的。换算函数只剩下乘除:
## 格子 → 像素:格子中心点。
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. 输入:点击和拖动
三消有两种交换手势,示例两种都支持:
- 点两下:先点一颗(出现选中框),再点它上下左右相邻的一颗。
- 拖动:按住一颗往某个方向拖,拖过一定距离就和那个方向的邻居交换。手机上就是滑动。
## 用 _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 像素)。会和哪一格交换?
- (5, 4)
- (5, 3)
- (4, 3)
- 不交换,因为方向是斜的
|dy| = 25 大于 |dx| = 18,所以取竖直方向;dy 是负数,即向上(Godot 的 y 轴向下为正)。目标是 (4, 3)。斜方向永远不会出现:代码只会产生四个方向之一。
4. 交换合法性
一次交换要同时满足两个条件才算"合法":
- 相邻:曼哈顿距离
|ax − bx| + |ay − by| = 1。对角线距离是 2,不算相邻。 - 交换后能形成至少一组三连。不满足的话要换回去(画面上会看到宝石过去又回来,下一章讲这段动画)。
第 2 条可以偷懒:交换只改变了 a、b 两格,其他格子原来没有三连,现在也不会凭空有。所以不用扫描全盘,只要看经过 a 的那一横一竖和经过 b 的那一横一竖:从这一格往左、往右数同色的个数,加上自己有没有到 3;上下同理。
## 判断交换 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,从下一段的开头继续,不回头。
## 扫描所有横向、纵向长度 ≥ 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),重复加入同一个键只会保留一个。
## 把共享格子的连续段合并成一组: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 形的炸弹会放在这里。
合并完,再按形状决定生成什么特殊宝石。优先级从高到低:
## 根据组的形状决定生成哪种特殊宝石。优先级: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)。合并后这一组有几个格子?会生成什么?
- 10 格,同色炸弹
- 9 格,同色炸弹
- 9 格,范围炸弹
- 10 格,范围炸弹
两段共用 (2, 2),所以是 5 + 5 − 1 = 9 格。最长段是 5,classify 先判断 longest >= 5,生成同色炸弹。
7. 开局不能自带三连
开局棋盘如果已经有三连,玩家还没动手就开始消除,这既不公平,也让"第一步"的设计失效。怎么生成一个没有三连的随机棋盘?
方法一:随机填满,有三连就整盘重来。写起来最简单,但要重试多少次?在 8×8 的棋盘上随机填色,用游戏里的 Board 实际试了 4000 次,没有三连的比例如下:
| 宝石种类 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|
| 一次就没有三连的概率 | 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 就一定有得选。
## 生成一个开局就没有三连的棋盘。按从左到右、从上到下的顺序填:
## 填 (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(),换过去、只看经过这两格的横竖线、再换回来。
## 所有可行的交换。每个格子只试"右"和"下"两个方向,避免同一对算两次。
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 开局,平均的可行步数:
| 宝石种类 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|
| 开局平均可行步 | 41.1 | 27.3 | 19.0 | 14.3 | 10.6 |
种类越多,可行步越少,玩家要花更多时间找,也越容易走进死局。很多三消游戏用 5~6 种颜色,就是在"太简单"和"老是死局"之间取的折中。
有了可行步列表,提示功能就很简单:玩家一段时间没有操作,就从列表里挑一步,让那两颗宝石轻轻地反复缩放。
## 提示一步:从所有可行步里随机挑一个(用特效专用的随机数,不影响棋盘的随机序列)。
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,看实时数据里的"可行步数"降到多少。
逐格填色时,为什么只检查"左边两格"和"上面两格",不检查右边和下面?
- 按从左到右、从上到下的顺序填,右边和下面还没有颜色,不可能和它组成三连
- 右边和下面的格子会在下落时被替换,不用管
- 为了性能,检查四个方向太慢
- 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 把这些步骤一段一段地演出来。