ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Godot原型+Flutter落地:中国象棋AI引擎跨平台开发实战

Godot原型+Flutter落地:中国象棋AI引擎跨平台开发实战 1. 项目缘起与整体架构思路1.1 为什么会有这个念头做独立开发的人大概都有过这种体验脑子里冒出一个想法觉得“这个应该不难”然后一头扎进去发现坑比想象中深得多。我这个项目就是这样来的。起因很简单我平时喜欢下象棋也一直在折腾各种小工具。某天突发奇想能不能把中国象棋的引擎塞进手机 App 里做一个随时随地能跟 AI 对弈的小应用不是那种调用在线接口的方案而是把引擎完整地跑在本地断网也能用响应还要快。这个需求听起来不复杂但真正动手之后我发现它涉及的技术栈跨度其实挺大引擎逻辑、跨平台 UI、性能优化、状态管理每一个环节都有坑。最终我选择的技术路线是Godot 做引擎验证 Flutter 做 App 落地这篇文章就把整个过程中的思路、踩坑和实操细节完整记录下来。1.2 技术选型的核心逻辑先说为什么用 Godot 而不是直接用 Flutter 写引擎。中国象棋引擎的核心是搜索算法本质上是大量递归计算加上局面评估。这类计算密集型任务用 GDScript 写原型非常合适——语法简单、迭代快、调试方便。我可以用 Godot 快速验证搜索深度、评估函数权重、走法生成逻辑是否正确不用一开始就陷入 Dart 的异步模型和 UI 框架里。这里有个关键判断原型验证和产品落地可以用不同的技术栈。很多人容易陷入“一个技术栈走到底”的思维定式但实际上用最适合原型的工具验证核心逻辑再用最适合产品的工具做落地效率会高很多。Godot 验证完引擎逻辑之后我需要把它“搬”到 Flutter 里。这时候面临一个选择是用 Dart 重写整个引擎还是通过某种方式复用已有的代码我最终选择了Dart 重写核心搜索逻辑 Flutter 做 UI 层的方案。原因有三点第一Dart 的性能对于象棋引擎来说够用AOT 编译之后执行效率不差第二纯 Dart 实现意味着没有原生依赖打包和分发都更简单第三Flutter 的热重载和状态管理机制对于调试引擎参数非常友好。1.3 整体架构分层整个 App 的架构我分成了三层引擎层纯 Dart 实现包含棋盘表示、走法生成、局面评估、搜索算法。这一层不依赖任何 Flutter 组件可以单独测试。通信层负责引擎和 UI 之间的数据传递。我用的是 Isolate 做后台计算避免搜索过程阻塞 UI 线程。UI 层Flutter 负责渲染棋盘、处理用户交互、展示对局信息。这个分层的好处是引擎层可以独立做单元测试UI 层可以独立做 Widget 测试通信层的接口一旦定好两边可以并行开发。2. 引擎核心从 Godot 原型到 Dart 实现2.1 棋盘表示与走法生成中国象棋的棋盘是 9 列 10 行共 90 个交叉点。最直观的表示方式是用一个长度为 90 的一维数组每个元素代表一个位置上的棋子。在 Godot 原型阶段我用的是 GDScript 的 Array每个元素是一个整数正数代表红方负数代表黑方0 代表空位。这种表示方式简单直接走法生成的时候遍历数组就行。但搬到 Dart 之后我发现这种表示方式在搜索过程中效率不够高。每次走子、撤销都要修改数组频繁的内存操作会影响搜索速度。于是我改用了位棋盘Bitboard的思路——虽然 Dart 没有原生的 64 位整数位运算但可以用多个 int 来模拟。具体来说我用两个 int 数组分别表示红方和黑方每个兵种的分布。比如redPawns是一个长度为 90 的布尔数组表示红方兵的位置。走法生成的时候通过预计算的走法表来快速查找。这里有个实操心得不要一上来就追求最优表示。我在 Godot 里用最简单的数组表示先把走法生成逻辑跑通确认规则没问题之后再在 Dart 里做优化。如果一开始就纠结位棋盘可能规则还没验证完就放弃了。走法生成的核心是预计算。中国象棋每个兵种的走法规则是固定的但受限于棋盘边界和“蹩马腿”“塞象眼”这类特殊规则。我的做法是预计算每个位置、每个兵种的“伪合法走法”不考虑将军和自将军搜索时再过滤掉会导致己方被将军的走法这样做的原因是伪合法走法的计算可以完全预计算成查找表搜索时只需要做一次过滤效率比每次重新计算高得多。2.2 局面评估函数的设计局面评估是引擎的“大脑”决定了 AI 的棋力风格。评估函数的核心是给每个局面打一个分数分数越高对红方越有利越低对黑方越有利。我的评估函数包含以下几个部分子力价值车 马 炮 兵 士象。具体数值我调了很多次最终用的是车 900、马 400、炮 450、兵 100过河兵 200、士象 200。位置价值同样是马在中心位置和在边线的价值不一样。我给每个兵种在每个位置都设了一个位置分。机动性走法多的局面通常更好但权重不能太高否则 AI 会变得“好动”。将帅安全被将军、将帅暴露等情况会扣分。注意评估函数的权重没有“标准答案”需要根据你想要的 AI 风格来调。如果你想要进攻型 AI就提高子力和位置价值的权重如果你想要稳健型 AI就提高将帅安全和防守的权重。在 Godot 里调参的时候我用了一个笨办法让 AI 自己跟自己下看哪种权重组合胜率更高。虽然效率不高但比手动调参靠谱得多。2.3 搜索算法从 Minimax 到 Alpha-Beta 剪枝最基础的搜索算法是 Minimax但它的效率太低搜索深度稍微大一点就跑不动。所以实际用的是Alpha-Beta 剪枝在 Minimax 的基础上剪掉不可能影响最终结果的分支。Alpha-Beta 剪枝的核心思想是如果已经找到一个走法能保证至少 X 分而另一个分支最多只能得到 X 分以下那这个分支就不用继续搜了。在 Dart 实现中我用递归实现 Alpha-Beta配合以下优化走法排序先搜索“看起来好”的走法比如吃子、将军这样能更早触发剪枝。置换表缓存已经搜索过的局面避免重复计算。迭代加深从浅到深逐步搜索浅层搜索的结果可以用来指导深层搜索的走法排序。int alphaBeta(int depth, int alpha, int beta, bool isRedTurn) { if (depth 0) return evaluate(); ListMove moves generateMoves(isRedTurn); sortMoves(moves); // 走法排序提高剪枝效率 for (Move move in moves) { makeMove(move); int score -alphaBeta(depth - 1, -beta, -alpha, !isRedTurn); unmakeMove(move); if (score beta) return beta; // 剪枝 if (score alpha) alpha score; } return alpha; }这段代码看起来简单但实际调试的时候坑很多。比如makeMove和unmakeMove必须严格配对否则棋盘状态会乱掉。我在 Godot 里就因为这个 bug 调试了一整天。3. Flutter 集成把引擎塞进 App 的关键步骤3.1 Isolate 通信让搜索不卡 UIFlutter 的 UI 线程和计算线程是分开的如果在主线程跑搜索算法界面会直接卡死。所以必须用 Isolate 把搜索放到后台。Isolate 之间的通信是通过消息传递实现的。我的做法是主 Isolate 把当前棋盘状态和搜索深度发给计算 Isolate计算 Isolate 跑完搜索后把最佳走法发回主 Isolate主 Isolate 收到结果后更新 UI// 主 Isolate 发送搜索请求 void requestSearch(Board board, int depth) async { final receivePort ReceivePort(); await Isolate.spawn(searchInBackground, { sendPort: receivePort.sendPort, board: board.toJson(), depth: depth, }); receivePort.listen((message) { if (message is Move) { applyMove(message); receivePort.close(); } }); }这里有个坑Isolate 之间传递的数据必须是可序列化的。Dart 的对象不能直接跨 Isolate 传递需要转成 JSON 或者用 TransferableTypedData。我一开始直接把 Board 对象传过去结果报错后来改成 JSON 序列化才解决。3.2 棋盘渲染CustomPaint 的实战技巧Flutter 里画棋盘最灵活的方式是CustomPaint。我定义了一个BoardPainter在paint方法里画棋盘线、棋子、选中状态。棋盘线的绘制相对简单就是横线和竖线。但中国象棋的棋盘有个特殊之处中间有“楚河汉界”两边的竖线在河界处断开。这个细节如果处理不好棋盘看起来会很奇怪。void paintBoard(Canvas canvas, Size size) { double cellWidth size.width / 8; double cellHeight size.height / 9; // 画横线 for (int i 0; i 10; i) { canvas.drawLine( Offset(0, i * cellHeight), Offset(size.width, i * cellHeight), boardPaint, ); } // 画竖线注意河界处断开 for (int i 0; i 9; i) { if (i 0 || i 8) { // 最左和最右的竖线是贯通的 canvas.drawLine( Offset(i * cellWidth, 0), Offset(i * cellWidth, size.height), boardPaint, ); } else { // 中间的竖线在河界处断开 canvas.drawLine( Offset(i * cellWidth, 0), Offset(i * cellWidth, 4 * cellHeight), boardPaint, ); canvas.drawLine( Offset(i * cellWidth, 5 * cellHeight), Offset(i * cellWidth, size.height), boardPaint, ); } } }棋子的绘制我用的是drawCircle加drawText每个棋子是一个圆形加一个汉字。这里有个细节棋子的文字要居中需要用TextPainter计算文字的实际宽高然后调整绘制位置。3.3 触摸交互从点击到走子用户点击棋盘的时候需要把屏幕坐标转换成棋盘坐标。这个转换的关键是知道棋盘的绘制区域和每个格子的大小。Position? screenToBoard(Offset tapPosition, Size boardSize) { double cellWidth boardSize.width / 8; double cellHeight boardSize.height / 9; int col (tapPosition.dx / cellWidth).round(); int row (tapPosition.dy / cellHeight).round(); if (col 0 || col 8 || row 0 || row 9) return null; return Position(col, row); }点击之后逻辑分两步第一次点击选中棋子第二次点击尝试走子。如果走法合法就执行走子并触发 AI 搜索如果不合法就取消选中或者重新选中。实操心得给用户一个“选中”的视觉反馈非常重要。我用的是一个半透明的圆形高亮选中之后棋子周围会有一个光圈。这个细节虽然小但体验提升很明显。4. 性能优化与常见问题排查4.1 搜索速度优化从 3 秒到 0.5 秒最初的版本搜索深度 4 层要跑 3 秒多用户体验很差。我做了以下几项优化优化项优化前耗时优化后耗时说明走法排序3.2s1.8s优先搜索吃子、将军走法置换表1.8s1.1s缓存已搜索局面评估函数简化1.1s0.7s去掉计算量大的机动性评估迭代加深0.7s0.5s浅层结果指导深层排序走法排序的效果最明显。原理很简单Alpha-Beta 剪枝的效率高度依赖于走法搜索顺序如果先搜索好的走法剪枝会早很多。置换表是另一个大杀器。象棋搜索中有大量重复局面比如“炮二平五、马8进7、马二进三”和“马二进三、炮8平5、炮二平五”可能到达同一个局面。用哈希表缓存这些局面的搜索结果能省下大量计算。4.2 常见问题速查表在实际开发和调试过程中我遇到了一些典型问题整理成表格方便排查问题现象可能原因排查方法解决方案AI 走子很慢搜索深度太大或剪枝失效打印每层搜索节点数降低深度或检查走法排序AI 走子不合法走法生成漏了规则用测试用例覆盖所有兵种补充蹩马腿、塞象眼判断UI 卡顿搜索在主线程执行检查是否用了 Isolate把搜索放到后台 Isolate棋盘显示错位坐标转换有误打印点击坐标和棋盘坐标检查 cellWidth 计算内存占用高置换表太大监控内存使用限制置换表大小或定期清理特别提醒蹩马腿和塞象眼的判断是最容易出 bug 的地方。我在 Godot 里测试的时候马可以穿过其他棋子走后来发现是蹩马腿的判断写反了。建议写完之后用固定的测试局面验证每个兵种的走法。4.3 引擎强度调优的实操经验引擎强度不是越高越好而是要和用户体验平衡。如果 AI 太强新手玩两把就放弃了如果太弱又没意思。我的做法是提供多个难度等级简单搜索深度 2评估函数加入随机扰动中等搜索深度 4正常评估困难搜索深度 6开启置换表和迭代加深随机扰动是个小技巧在评估分数上加一个小的随机值让 AI 在多个差不多的走法之间随机选择避免每局都走一样的棋。另外我还加了一个“思考时间”的限制。即使搜索还没完成超过设定时间也要返回当前最佳走法。这样用户不会等太久AI 的棋力也不会太差。5. 从原型到产品的完整流程复盘5.1 开发阶段的时间分配整个项目从想法到能用的 App大概花了三周左右的业余时间。时间分配大致如下第一周Godot 原型验证。包括棋盘表示、走法生成、基础搜索算法。这一周的目标是“能下棋”不追求性能。第二周Dart 重写和 Flutter 集成。把 Godot 验证过的逻辑用 Dart 重写同时搭建 Flutter UI。这一周最痛苦因为要处理 Isolate 通信和 CustomPaint 的细节。第三周性能优化和体验打磨。包括搜索速度优化、难度分级、UI 细节调整。如果重新来一次我会在 Godot 阶段就把评估函数的框架搭好这样搬到 Dart 的时候只需要调参不用重新设计。5.2 跨技术栈迁移的核心原则从 Godot 到 Flutter 的迁移我总结了几个原则第一逻辑和表现分离。在 Godot 里写引擎的时候我就刻意不让引擎逻辑依赖任何 Godot 的节点或场景。引擎就是一个纯逻辑模块输入棋盘状态输出走法。这样搬到 Dart 的时候只需要把 GDScript 翻译成 Dart不用重新设计架构。第二先跑通再优化。我在 Godot 里用的是最朴素的数组表示和递归搜索搬到 Dart 之后才做位棋盘和置换表优化。如果一开始就追求最优实现可能原型阶段就卡住了。第三测试用例要跟着走。我在 Godot 里写了一套测试局面验证每个兵种的走法是否正确。搬到 Dart 之后这套测试用例直接复用省了很多验证时间。5.3 后续可以扩展的方向这个 App 目前已经能正常对弈但还有很多可以完善的地方。比如开局库让 AI 在开局阶段走一些常见套路而不是纯靠搜索。残局库对于棋子较少的局面直接查表比搜索快得多。棋谱记录把对局过程保存下来支持复盘。联网对战虽然本地引擎已经够用但联网对战是另一个维度的需求。不过这些都是后话了。对我来说这个项目最大的收获不是做出了一个象棋 App而是验证了一条跨技术栈的开发路径用最适合原型的工具验证核心逻辑再用最适合产品的工具做落地。这个思路可以复用到很多其他项目上。最后分享一个小技巧如果你也在做类似的引擎类项目建议在开发阶段加一个“调试面板”实时显示搜索深度、节点数、评估分数这些信息。我在 Godot 里加了这个面板之后调参效率至少提升了一倍。Flutter 里也可以用Overlay或者单独的调试页面来实现非常值得投入。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进