资讯动态

消灭星星自动求解:基于光束搜索与BFS的棋盘建模与游戏AI实现

发布时间:2026/10/19 19:11:14 来源:尧图企业网站定制
简介消灭星星自动求解是一份基于QT框架的C游戏AI项目资源面向对游戏自动化与智能决策感兴趣的开发者解决如何在有限步数内通过消除同色方块获取高分的问题。压缩包共60个文件大小12.13MB包含6个.h与6个.cpp源码、可执行程序、22个dll动态库、15个qm语言包、图片资源及工程配置既能在QT环境中重新编译也能直接启动体验。程序采用单步最优蒙特卡洛方法逐回合模拟所有可消除组合并评估预期得分再执行当前最优策略实测平均得分可稳定超过5500。目前已有605人学习下载资源完整呈现了游戏状态建模、消除组合生成、蒙特卡洛采样与界面刷新的实现细节适合希望深入学习随机搜索算法落地、图形界面与计算线程协作的中高级读者也可作为游戏AI课程设计或二次开发的起点通过研读源码与算法流程可掌握启发式评估与单步决策的实用技巧。1. 消灭星星自动求解为什么人工拿不到高分脚本却可以消灭星星这个游戏看起来无脑10x10 的棋盘上散着五种颜色的星星点掉一块同色连通区域剩下的会掉下来并自动左移就这样一路点到终局。可分数一上去就会发现这套规则真正难的是选择而不是手速。一个连消能拿多少分取决于你为后面留下的是“整片同色”还是“碎成渣”。所以自动求解在本质上是搜索问题每一步有几十种合法操作整棵树指数膨胀纯贪心必然翻车。这份资源把棋盘建模、连通块识别、光束搜索、终局回放器都打包好了适合想做游戏 AI 课设、练搜索算法或者研究自动化脚本的开发者直接复现。下面按我实际拆包调试的顺序往下讲连坐标方向这种小坑都会点出来。2. 先把棋盘和操作建模从二维数组到“删除一坨”的模拟动手写搜索之前棋盘数据结构必须稳定。很多人的求解器跑着跑着就得出非法步不是算法问题是删除模拟和坐标定义对不上。我把这套资源里最核心的三段建模代码拆出来顺序分别是棋盘生成、连通块识别、删除后的重力与整列左移。2.1 棋盘数据结构与五色编码别用字符串当底层常见做法是用二维列表存棋盘颜色用整数 0~4 编码-1 表示该位置已经消掉。用整数的好处是判断颜色快、内存小而且后续哈希局面状态也方便。不要用字符串拼棋盘虽然打印好看但每次更新要重建字符串搜索层数一高就难受。# board 用二维列表行 r 从 0 到 9列 c 从 0 到 9 # 颜色编码 0~4-1 表示空位 import random def random_board(rows10, cols10, colors5): return [[random.randrange(colors) for _ in range(cols)] for _ in range(rows)]这段代码的默认参数就是 10x10、5 色。我一般会把rows、cols、colors留成参数因为做小规模调试时把棋盘缩到 5x5 会比在完整棋盘上跑快很多。注意 0 也是合法颜色判断空位时一定要用 -1不要用if not board[r][c]这种写法否则颜色 0 会被当成空。这个细节在下一段 BFS 里最容易翻车。2.2 连通块识别BFS 的 visited 标记要放在入队时连通块识别是整棵搜索树的地基。搜索每一步都要先找出所有同色且上下左右相连的块才能生成候选操作。这里用队列实现 BFS比递归 DFS 稳因为棋盘虽然只有 100 格但搜索过程会反复调用递归深度一多照样把调用栈打爆。from collections import deque def find_clusters(board, min_size2): rows, cols len(board), len(board[0]) visited [[False] * cols for _ in range(rows)] clusters [] def bfs(sr, sc): color board[sr][sc] q deque([(sr, sc)]) visited[sr][sc] True cells [] while q: r, c q.popleft() cells.append((r, c)) for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): nr, nc r dr, c dc if not (0 nr rows and 0 nc cols): continue if visited[nr][nc] or board[nr][nc] ! color: continue visited[nr][nc] True q.append((nr, nc)) return cells for r in range(rows): for c in range(cols): if visited[r][c] or board[r][c] -1: continue cluster bfs(r, c) if len(cluster) min_size: clusters.append(cluster) return clusters这里有个很关键的习惯visited必须在入队那一刻就置位而不是出队再置位。否则同一个格子可能被入队多次BFS 返回的连通块里会重复出现坐标后面算分、回放全乱。min_size2是按常见规则来的如果你的版本允许消单个星星把它改成 1 就行这个参数后续调参也会用到。2.3 重力下落与整列左移模拟顺序错了答案全错消掉一块后规则分两步走先让每一列的星星受重力落到最下面再把整个空列挤掉、右侧列向左补齐。顺序非常重要先左移再下落会把棋盘变成完全不同的局面。这一段的坐标方向也容易看晕我习惯统一用“r 从 0 是顶行c 从 0 是最左列”。def apply_move(board, cluster): # 先拷贝避免污染原始棋盘 board [row[:] for row in board] # 1. 把 cluster 里的格子标记为空 for r, c in cluster: board[r][c] -1 rows, cols len(board), len(board[0]) # 2. 重力下落按列取保留星从下往上填 for c in range(cols): kept [board[r][c] for r in range(rows) if board[r][c] ! -1] for r in range(rows): board[r][c] -1 for r in range(rows - len(kept), rows): board[r][c] kept[r - (rows - len(kept))] # 3. 整列左移只保留非空列 new_board [] for c in range(cols): col [board[r][c] for r in range(rows)] if any(v ! -1 for v in col): new_board.append(col) while len(new_board) cols: new_board.append([-1] * rows) # 4. 从列结构转回行结构 board [[new_board[c][r] for c in range(cols)] for r in range(rows)] return board这段代码最容易出错的是第 2 步里的索引换算。kept是按“从顶到底”的顺序取的填入时也要从底部往上填所以起始行是rows - len(kept)kept的下标则是r - (rows - len(kept))。如果你把行顺序反过来星星就会悬浮在中间或者上下颠倒。第 3 步我先收集非空列再补空列最后转置回行结构。这个写法牺牲一点性能但每一步都直观调试时一眼能看出问题。3. 自动求解核心贪心打底光束搜索提上限模型建好之后真正的问题是搜索策略。有人会直接写一个贪心每步挑得分最高的块消。这样跑起来飞快但分数经常卡在中等水平。原因很现实这一步拿的高分可能把之后的整片同色拆成碎片。所以这份资源的求解核心是光束搜索它保留了“贪心”的即时收益思路又加上了评估函数的前瞻能力。3.1 即时得分与终局得分的反差贪心为什么翻车先看得分公式。这份资源默认的计分方式是消除 k 个星星得k*(k-1)*5这跟市面上一部分版本的规则一致。如果换成k*10这类线性公式搜索的排序逻辑也会变化所以我把计分函数单独抽出来。def move_score(k): # 默认计分消除 k 个星星给 k*(k-1)*5 # 你的游戏版本若不同只改这个函数 return k * (k - 1) * 5这个公式决定了“消大块”必须优先。举个例子消一个 5 连给 100 分而两次 2 连分别只给 20 分差距明显。可问题在于把一个大块直接消掉可能会让上面的星星落下来时自然形成另一个大块如果把旁边的小块先消掉反而能让两个区域合并成更大的块总分更高。贪心只能看到“现在”看不到“这一步操作对剩余棋盘连通性的影响”。光束搜索就是在这一步折中。3.2 光束搜索搜索宽度怎么压下来朴素 BFS 会同时保存所有中间棋盘棋盘只有 100 格的时候还能扛再加 5 种颜色、几十个合法块内存和时间直接崩。光束搜索的做法是每层只保留得分期望最高的若干个状态这个数量叫光束宽度。别把它理解成简单的 Top-K 贪心搜索过程中每个状态都会生成全部合法子节点只是最后截断。def beam_search(board, beam_width20, max_depth30): # 状态 (累计得分, 棋盘, 操作序列) beam [(0, board, [])] best (0, board, []) for _ in range(max_depth): candidates [] for total, cur_board, history in beam: clusters find_clusters(cur_board) if not clusters: # 无法再消直接作为终局候选 if total best[0]: best (total, cur_board, history) continue for cluster in clusters: nxt apply_move(cur_board, cluster) gain move_score(len(cluster)) candidates.append((total gain, nxt, history [cluster])) if not candidates: break # 排序时同时看累计得分和评估函数 candidates.sort(keylambda x: eval_board(x[1]) x[0], reverseTrue) beam candidates[:beam_width] if beam[0][0] best[0]: best beam[0] return best这里两个参数决定搜索行为。beam_width20表示每层最多保留 20 个候选调大会更接近穷举但内存随之上涨max_depth30表示搜索最多走 30 步对于 10x10 棋盘如果每步至少消 2 个星30 步已经覆盖大部分对局。需要说明的是beam 排序用的是eval_board totaltotal是已经拿到的分数eval_board是评估函数给出的“未来潜力”两者量纲要大致匹配否则会出现一种情况一个状态总分很高但局面已经死了另一个状态总分略低但后面还有爆发排序却把前者留在光束里。3.3 评估函数剩余元素、碎片化与颜色惩罚评估函数是光束搜索的“导向标”。最朴素的评估是看剩余星星数剩余越少越好但这个指标太粗两个棋盘剩余星星一样多一个全是同色大片一个全是孤立点后续得分天差地别。我一般会同时统计最大连通块占比、连通块数量和剩余颜色种类。def eval_board(board): clusters find_clusters(board, min_size1) # 算评估时连 1 星也要看 alive sum(cell ! -1 for row in board for cell in row) if alive 0: return 1e9 # 清盘最高分 max_ratio max(len(cluster) for cluster in clusters) / alive fragmentation len(clusters) / max(1, alive * 0.1) colors {cell for row in board for cell in row if cell ! -1} # 三个可调权重 return max_ratio * 100 - fragmentation * 5 - len(colors) * 2这段代码的意图是大片存在感越强越好碎片越多越差颜色种类越少越好。alive * 0.1这个分母是为了做归一化记忆避免两个不同剩余数的棋盘比较不了。权重 100、5、2 不是玄学你可以先跑几局观察失败局面里“碎片化”和“颜色种类”哪个更早导致死局再上下调。如果追求更稳也可以把find_clusters(min_size1)换成只找大块但那样会丢失对“能不能合并”的刻画。4. 参数调参与避坑五个坑让你的求解器越搜越差光有代码还不够这套资源我在复现时踩了五个坑每一个都让搜索结果变得不可信。这一章专门写排障你按“现象 → 原因 → 解决”的顺序看能省下大半天。4.1 五个踩坑记录现象、原因、解决第一个坑删除模拟后棋盘坐标错乱回放器判定非法。原因是我在做重力下落之前先做了整列左移或者把行、列下标搞反。解决方法是把apply_move拆成两个独立的子过程先重力后左移。我还会打印一个随机局面删除前后的完整棋盘肉眼比对一遍再继续。第二个坑用递归 DFS 找连通块10x10 的棋盘上递归深度一高就抛异常。原因很简单递归栈被反复调用撑爆。解决方法是全部改成 deque 队列的 BFS并且visited在入队时置位。从那以后我再也没有因为这个崩过。第三个坑beam_width调得越大结果反而越差。初看很反直觉后来发现是排序权重问题。eval_board的量纲远大于单步得分导致光束里全是“评估函数看着好但累计得分低”的状态搜索目标跑偏。解决办法是调参前先做一次消融测试只按total排序跑一版再按eval_board total跑一版对比终局分数。第四个坑换了一个游戏版本后脚本跑出的“高分”在真实游戏里不算数。原因是计分公式和是否允许消单星的规则不一致。解决方法是把move_score和min_cluster都暴露成配置项先用真实手打录像去核对同一个操作序列的得分确认公式对了再跑搜索。第五个坑搜索停在同一个局面反复抖动。原因是多个不同操作序列可能落到同一个棋盘状态光束里保存了大量重复状态白白占掉宽度。解决方法是给棋盘做序列化哈希把每行数字拼成字符串加入候选前先去重平局时用固定随机种子决定保留哪个保证结果可复现。4.2 参数调试清单先钉死规则再动搜索参数调参不是一上来就改 beam_width。我一般先把规则参数钉死再把搜索参数从缺省值开始放大。下表是这套资源里最常动的 5 个参数。参数默认值作用翻车临界点min_cluster2小于该大小的连通块不参与候选设成 1 会大幅增加候选数还可能让计分函数出现 0 分操作move_scorek*(k-1)*5单步得分公式与版本不一致时搜索树排序全错beam_width20每层保留候选状态数超过 50 后内存上涨明显收益边际递减max_depth30搜索的最大步数太小看不到中局太大遇到死局会空转random_seed42平局时决定保留哪个状态不设置时每次结果都不一样没法复现调试顺序我会这样走先在 5x5 的小棋盘上跑beam_width5、max_depth10确认每一步局面变化合理再把棋盘换回 10x10beam_width从 10 逐步加到 30记录每一步耗时和终局分数。如果耗时增长很快但分数不涨通常要回头调评估函数而不是继续加宽度。记住一点光束搜索的宽度只是兜底提升上限的是评估函数对“死局”的敏感度。5. 终局验证与进阶技巧用回溯器确认“每一步都合法”搜索算法跑出来的操作序列不能直接拿去当成标准答案因为只要apply_move里有一个坐标错位整条路径都废了。所以这套资源里我最看重的是回放验证器。它把初始棋盘和操作序列一起喂进去重新执行每一步逐条检查这一步的连通块在当时的棋盘上是否合法顺便把累计得分也算出来。这个步骤相当于给求解器加了一层约束能堵住大部分模拟漏洞。def replay_validator(initial_board, moves): board [row[:] for row in initial_board] total 0 for step, cluster in enumerate(moves, 1): # 检查 cluster 在当前棋盘上是否是一个完整连通块 legal False for cand in find_clusters(board): # 顺序无关只比坐标集合 if set(cand) set(cluster): legal True break if not legal: return False, step, total, board total move_score(len(cluster)) board apply_move(board, cluster) return True, len(moves), total, board这段代码里有几个细节要留意。比较连通块时不要直接用“坐标列表相等”因为 BFS 返回的顺序和搜索时记录的顺序可能不同必须转成集合再比。其次move_score要在调用apply_move之前算因为棋盘更新后原来的连通块已经不存在了。最后返回值里带上每一步之后的棋盘这样定位非法步时可以直接打印那一层局面。比回放更进一步的技巧是给搜索加上“死局早停”。所谓死局就是棋盘上已经没有合法的可消块但还剩下一堆星星没消掉。在beam_search里我可以把评估函数扩展成遇到死局时剩余星星越多惩罚越重。这样一来光束会把那些“暂时分数高但必然烂尾”的状态提前过滤掉。死局检测本身并不复杂复用find_clusters即可def is_dead(board): # min_size 与搜索时的合法规则保持一致 return len(find_clusters(board, min_size2)) 0把这个函数加进eval_board比单纯调大beam_width管用得多因为你在搜索源头就把注定失败的路径排除了。从那以后我每次改完移动模拟或计分公式都会强制把最近一次求解的输出保存成 JSON再跑一遍回放器逐条核宁可慢十秒也懒得事后拍大腿。希望这套建模、光束搜索、回放的组合能帮你少踩几个坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑