用C++和Qt从零实现黑白棋AI对战:核心算法与界面设计
2026/9/9 12:02:45 网站建设 项目流程

简介:这是一份基于C++和Qt框架开发的黑白棋(翻转棋)项目源码,面向正在学习C++ GUI编程、需要课程设计或想了解棋类游戏实现的开发者。代码用二维数组表示8x8棋盘,完整实现了落子合法性判断、水平垂直与对角线方向的翻子逻辑、无法落子时的胜负判定,并通过Qt的信号槽与鼠标事件建立起直观的人机/双人对战交互。界面层使用Qt控件绘制棋盘并显示当前玩家、回合数等信息,整体结构清晰,便于从入口文件一路追踪到核心算法。压缩包共28个文件,其中包含7个cpp源码、4个头文件、ui界面描述和pro工程文件,同时附有Windows下可直接运行的exe、makefile及debug/release构建产物,整体仅1.12MB,轻量且完整。当前已有361人学习下载,适合对照源码和运行效果理解黑白棋规则与Qt事件循环的配合,也可在此基础上继续补充AI搜索或存档读档功能,作为进一步拓展的起点。 我最早接触黑白棋(Reversi/Othello),是大学时在别人的诺基亚手机上玩到停不下来。后来自己也用C++写过“五子棋”“贪吃蛇”,但真正把“AI对战+图形界面”完整做出来,反而是用Qt重写黑白棋的时候——因为只有双人对称棋类,才会逼着你把局面评估、搜索剪枝、事件响应这些模块串成一个真正能跑的东西。这篇文章就把我当时从零到一实现“黑白棋 C++ Qt”的过程、踩坑和最终方案完整写出来,给你一份可以直接“抄作业”的思路。

1. 项目整体设计与思路拆解

1.1 为什么选C++和Qt来做黑白棋

先聊一个大家都会纠结的问题:做棋类小游戏,Python配pygame不香吗?甚至纯JavaScript在浏览器里跑不是更方便?

我的看法是:如果目的只是“跑起来”,确实用啥都行;但如果你想练C++的工程能力、理解事件驱动编程,同时做出一个跨平台、外观不廉价的桌面应用,Qt几乎是最合适的组合。

C++负责核心逻辑,包括棋盘状态、落子合法性判断、翻转算法、AI搜索评估,这些逻辑需要高效且可控的内存管理,C++天然合适。Qt则负责界面部分,它的QPainter绘制机制非常适合做棋盘这种需要频繁重绘的场景,而且信号槽(Signals & Slots)机制让“玩家点击棋盘 → 调用逻辑模块 → 更新界面”这条链路变得非常清晰——比到处写回调舒服太多了。

另外,Qt的跨平台能力也值得一提。我最初在Windows上开发调试,后来把整个项目丢到Linux上,用Qt Creator直接打开.pro文件就能编译运行,几乎没做任何平台相关的改动。这一点在学习和做作品集时都很加分。

1.2 黑白棋规则里最容易被忽略的核心点

说到规则,很多人第一反应是“黑白双方轮流下棋,夹住对方棋子就翻色”。但真正动手写代码时,你会发现有几个细节必须提前想清楚:

  • 合法落子点的判定:一个位置合法,要求从这个位置出发,至少在某个方向(共8个方向)上,存在连续的一个或多个对方棋子,且末端是你的棋子。更直白说,新下的棋子必须能“夹住”至少一枚对方棋子。
  • 无合法着法时的处理:如果一方当前没有任何合法位置,这一方必须跳过回合,由对方继续下。这一步是新手最容易漏的——UI阶段玩家发现自己下不了棋,实际是程序漏了“pass(跳过)”逻辑。
  • 游戏结束的判定:棋盘下满,或者双方都无合法着法时游戏结束,统计黑白子数量,多者胜。

这三个点听起来像废话,却是黑白棋程序里最容易出Bug的地方。我在第一次写的时候跳过规则,结果AI经常“傻掉”——明明能下却一直pass,后来才发现是自己写合法判断时漏掉了“必须存在至少一个方向能翻转”这个条件。

1.3 技术选型与模块划分

整体技术方案如下:

模块选型说明
开发语言C++11/14兼容性好,Qt 5系列都支持
GUI框架Qt 5.15(Widgets)用QPainter绘制棋盘,比QML更容易入门
构建工具qmake或CMake单文件用qmake方便,后续扩展建议CMake
AI算法极小极大搜索 + Alpha-Beta剪枝固定深度4~6层,配合静态评估函数

我把项目按经典的三层结构划分:

  1. 逻辑核心层(Board类):管理棋盘状态、落子、翻转、胜负判断、合法位置计算。这一层完全不依赖Qt头文件,方便单独测试,也方便以后移植成命令行版本或服务器版本。
  2. AI引擎层(AI类):接收一个棋盘状态,返回最优落子坐标。引入搜索算法和评估函数。
  3. 界面层(MainWindow+BoardWidget:负责棋盘绘制、鼠标事件、菜单栏、状态提示。

这个分层几乎是照着教科书来的,但真到了写代码的时候,很多人会因为“图快”把逻辑和界面混在一起,导致后面AI调优、加新功能(比如悔棋、复盘)时吃尽苦头。我建议一开始就严格分层——哪怕前期多写两行接口代码,后面省的时间远不止两小时。

2. 核心细节解析与实操要点

2.1 棋盘表示方案:二维数组够用吗

黑白棋标准为8×8格子,最直观的表示方式就是int board[8][8]。比如0表示空,1表示黑子,2表示白子。这样做的好处是代码可读性强,调试时打印也方便,比如写一个简单的函数把棋盘输出到控制台,配合单元测试很舒服。

但如果你对性能有更高追求(比如做一个能深度搜索到10层以上的AI),业界常用位棋盘(Bitboard)——用两个uint64_t整数分别表示黑子和白子的分布,利用位运算完成翻转和合法位置计算。一次位运算能同时处理整行/整列/整斜线,速度是二维数组方案的几十倍。

我的建议是:先基于二维数组把功能跑通,因为位棋盘的可读性差,位运算的Debug难度大,不适合初学者直接上手。等把AI搜索深度从4层往上提的时候,再做一次性能分析和迁移也不迟。接口上只要保证getBoard()setPiece()这类函数稳定,后续替换内部实现并不伤筋动骨。

2.2 落子合法性判断与翻转逻辑

这一部分是整个程序的核心,千万别马虎。我这里给出一个常见的实现思路,你直接照做就行。

8个方向可以用方向向量来表示:dx = {-1, -1, -1, 0, 0, 1, 1, 1}dy = {-1, 0, 1, -1, 1, -1, 0, 1}。对某个候选位置(row, col),依次检查每个方向:

  1. 向该方向走一步,如果越界或遇到的不是对方棋子,则continue。
  2. 继续沿该方向走,直到遇到空白格或走出边界——如果是空白格,说明没有夹住;如果是自己的棋子,说明该方向可翻转。
  3. 只要有一个方向可翻转,该落子合法。

翻转时,再次沿这个方向走回去,把路上的对方棋子全部变成自己的。注意:翻转操作必须在确认该位置合法之后统一执行,不能边检查边翻转,不然方向判断会互相干扰。

// 伪代码示意:判断合法性 bool isLegal(int row, int col, int player) { if (board[row][col] != EMPTY) return false; for (int d = 0; d < 8; d++) { int r = row + dx[d], c = col + dy[d]; if (outOfBound(r, c) || board[r][c] == EMPTY || board[r][c] == player) continue; r += dx[d]; c += dy[d]; while (inBound(r, c) && board[r][c] == opponent(player)) { r += dx[d]; c += dy[d]; } if (inBound(r, c) && board[r][c] == player) return true; } return false; }

这段代码我第一次写的时候走了弯路——我把“检查”和“翻转”写进了同一个函数里,导致判断成功时棋盘已经被改了,后续逻辑一片混乱。强烈建议把两个操作彻底分开:hasLegalMove()只做判断,applyMove()只做落子+翻转。各司其职,Debug体验天差地别。

2.3 AI算法的选择与评估函数设计

黑白棋AI这块,我分两个阶段来写。

第一阶段:贪心策略(初期版本)

最简单的AI是“能翻最多就下哪”。这个实现5分钟就能写完,也很好验证。但实际对局你会发现它非常吃亏——黑白棋的一大特点是“让对手少行动”,一个子翻得最多,有时反而给对方创造了边角机会。贪心策略能打毫无章法的新手,但打不过任何会看两步的人。

第二阶段:极小极大搜索 + Alpha-Beta剪枝(最终方案)

核心思路是:假设双方都足够聪明,我方选能让我方评估值最高的走法,对方会选让我方评估值最低的走法。搜索到叶子节点时,用静态评估函数打分。

评估函数是黑白棋AI的“灵魂”。我的方案有四个部分,按权重叠加:

  • 子数差:当前玩家棋子数减对方棋子数。这是最弱的指标,因为黑白棋里子多不代表优势,甚至可能“子多局面差”。
  • 边角优势:角是黑白棋的必争之地,因为它永远不会被翻转。四角给很高权重,比如每个占有角+50分。
  • 稳定子数量:所谓稳定子,就是永远不可能被对方翻转的己方棋子,尤其角周围的边子链。这部分实现稍复杂,但很有效。
  • 行动力(Mobility):当前玩家能下的合法位置数。黑白棋有一个原则——开局阶段,让对手无棋可下比你多占几个子重要得多。我实盘测试也发现,加强了行动力权重后,AI整体水平提升极其明显。

最终评估函数大概是:

score = 自身合法步数*10 + 角数*50 + 稳定子数*20 - 对手合法步数*10 - 对手角数*50 - 对手稳定子数*15

搜索深度我设置为4~6层。经过Alpha-Beta剪枝后,即使不加置换表,普通的8×8局面也能在几百毫秒内完成计算,玩家体验基本流畅。

2.4 界面绘制方案:QPainter还是QLabel

做棋盘界面,最简单粗暴的方式是49个QLabel(或者64个)格子拼一个棋盘,点击时给每个QLabel绑定事件。确实能跑,但我强烈不推荐——刷新慢、代码冗余、后期想加动画效果几乎无从下手。

更好的方案是自定义一个QWidget子类,重写paintEvent(),用QPainter一次性绘制整个棋盘。

绘制要点包括:

  1. 棋盘底色与网格线:绿色或木质风格背景,用drawRectdrawLine画出8×8网格。
  2. 棋子绘制:用drawEllipse画圆,填充黑色或白色,边缘加一点渐变或描边效果,视觉上会好很多。实测用QRadialGradient做棋子高光,程序性能几乎不受影响,观感提升却非常明显。
  3. 合法位置提示:在合法落子点画一个半透明小圆点,用setOpacity轻松实现。这是新手玩家体验的关键功能,没有它玩家会觉得“这游戏是不是坏了,我点哪都不能下”。
  4. 最后一手标记:把上一步的落子位置用红框或特殊颜色圈起来,方便双方观察局势。

在鼠标事件方面,在mousePressEvent()里通过坐标换算将像素坐标映射为棋盘行列坐标:row = event->pos().y() / cellSizecol = event->pos().x() / cellSize,然后调用逻辑层接口。注意要检查该格是否合法,非法点击不做响应,或给出提示。

提醒:paintEvent()里不要做任何耗时操作,也不要在里面new对象。所有棋子数据的计算应该提前完成,绘制函数只做“画”。如果你发现界面偶尔卡顿,先检查是不是在paintEvent()里做了循环搜索。

2.5 让界面与逻辑解耦:信号槽的正确用法

我的界面代码和逻辑代码通过信号槽通信,典型的流程是:

  • 玩家点击棋盘 →BoardWidget发送moveMade(int row, int col)信号。
  • MainWindow里连接该信号到逻辑层GameController的槽函数。
  • GameController调用Board::applyMove()修改状态,再检查游戏是否结束。
  • 如果是人机模式,轮到AI时调用AI::getBestMove(),得到结果后再次更新界面。

用信号槽的好处是:当你某天想增加“网络对战”“AI自动对战”功能时,不需要改动界面代码,只需要新建一个类发出同样的moveMade信号即可。这是面向对象设计里“依赖倒置”原则的精髓。

3. 实操过程与核心环节实现

这一章直接上硬菜。我会按从界面到AI的顺序,把关键代码和参数选择过程拆开讲。

3.1 工程搭建:从Qt Creator到第一版空棋盘

我用的是Qt 5.15.2 + Qt Creator 4.x,Windows上安装时勾选MSVC 2019 64-bit组件(如果你用MinGW编译器,就勾选MinGW对应组件,注意编译器位数和Qt库要一致,这是最常见的坑)。

新建工程时选“Qt Widgets Application”,基类选QMainWindow。创建完成后,我习惯把自动生成的MainWindow拆成两个核心文件:

  • boardwidget.h/cpp:实现棋盘的绘制和鼠标交互。
  • mainwindow.h/cpp:负责菜单栏、状态栏和游戏控制按钮的布局。

.pro文件里,只需要保留基本的QT += core gui widgets。如果后面你加音频、网络功能再额外加模块就行。

BoardWidget里最关键的是记录单元格大小cellSize和棋盘偏移量margin。我用的是:cellSize = 60margin = 30,这样棋盘总宽为30*2 + 8*60 = 540像素,窗口初始大小设置为600 x 600左右,视觉效果刚好。

// BoardWidget 核心绘制示例(简化版) void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); // 抗锯齿,画圆必备 // 绘制棋盘背景 painter.setBrush(QColor(34, 139, 34)); painter.drawRect(margin - 5, margin - 5, cellSize * 8 + 10, cellSize * 8 + 10); // 绘制网格线 painter.setPen(QPen(QColor(255, 255, 255), 1)); for (int i = 0; i <= 8; i++) { painter.drawLine(margin + i * cellSize, margin, margin + i * cellSize, margin + cellSize * 8); painter.drawLine(margin, margin + i * cellSize, margin + cellSize * 8, margin + i * cellSize); } // 根据board状态绘制棋子 for (int r = 0; r < 8; r++) { for (int c = 0; c < 8; c++) { int val = board[r][c]; if (val == EMPTY) continue; QColor color = (val == BLACK) ? Qt::black : Qt::white; painter.setBrush(color); painter.drawEllipse(margin + c * cellSize + 4, margin + r * cellSize + 4, cellSize - 8, cellSize - 8); } } }

3.2 游戏状态管理与胜负判断

我单独写了一个GameController类,负责维护当前轮到谁下、游戏状态(进行中/已结束)、统计棋子数,并协调BoardAI

关键逻辑如下:

void GameController::onPlayerMove(int row, int col) { if (gameOver) return; if (currentPlayer != HUMAN_PLAYER) return; if (!board.applyMove(row, col, currentPlayer)) return; // 切换玩家 currentPlayer = opposite(currentPlayer); // 如果当前玩家无合法走法,跳过 if (!board.hasAnyLegalMove(currentPlayer)) { currentPlayer = opposite(currentPlayer); if (!board.hasAnyLegalMove(currentPlayer)) { gameOver = true; updateScore(); emit gameFinished(blackCount, whiteCount); return; } } updateUI(); }

有一个很容易踩的坑:“玩家落子”和“AI落子”的入口不能混在一起。我一开始图省事,在同一个函数里处理点击事件和AI返回结果,结果AI和玩家互相“抢走子权”,调试了一晚上才发现是状态没锁住。解决办法就是:先判断currentPlayer,再决定接受鼠标输入还是调用AI,两者互不干扰。

3.3 AI模块:从评估函数到Alpha-Beta搜索

AI实现我建议分两步。先完成一个无剪枝的极小极大版本,游戏默认从第4回合左右进入中盘,搜索深度3层时,一个普通8×8局面大概要评估几十万局面,速度还能接受。确认无误后,再叠加Alpha-Beta剪枝,深度可以提至5~6层。

AI::getBestMove(Board board, int depth)返回一个QPair<int, int>,表示落子坐标。我用的搜索框架如下:

int AI::alphaBeta(Board &board, int depth, int alpha, int beta, int player) { if (depth == 0) return evaluate(board, player); QList<QPair<int,int>> moves = board.getLegalMoves(player); if (moves.isEmpty()) { // 无子可下,切换玩家继续搜 if (!board.hasAnyLegalMove(opponent(player))) { return evaluate(board, player); // 双方都无子,终局 } return -alphaBeta(board, depth - 1, -beta, -alpha, opponent(player)); } int best = -1000000; for (auto move : moves) { Board newBoard = board; newBoard.applyMove(move.first, move.second, player); int val = -alphaBeta(newBoard, depth - 1, -beta, -alpha, opponent(player)); if (val > best) best = val; if (best > alpha) alpha = best; if (alpha >= beta) break; // 剪枝核心 } return best; }

这段代码基于一个数学事实:由于棋类游戏是对称的,我可以将“轮到我下时取最大值”等价转化为“轮到我方时取最大值,到对方时对评估结果取负号”。这样写代码时不用区分极大/极小两个函数,逻辑也更紧凑。初学者如果对这里不熟,建议先看我这一段再回去读算法书,理解“负极大值”这套写法后,AI代码会干净很多。

3.4 评估函数里那些需要翻车的细节

评估函数看起来简单,但有很多雷区。

第一个雷:不要只用子数差评估。黑白棋的子数差在前20手基本没有参考意义,盲目追子数的AI会被对手疯狂占角。我测试过,纯子数差的AI和我写的带边角权重的AI对局,10盘全输。

第二个雷:要注意评估在谁的角度。在负极大值框架里,evaluate()返回的分数必须始终站在当前搜索节点对应的玩家角度(即传入的player参数)。如果统一返回固定玩家角度的分数,交换玩家时会出大问题。我的做法是:内部计算“当前玩家分数 - 对手分数”,这样天然符合负极大值对评估的要求。

第三个雷:搜索深度和性能的平衡。我用Qt的QElapsedTimer实测过:采用简单的二维数组+Alpha-Beta剪枝,不开置换表,中盘局面深度4约耗时50~150ms,深度5约200~600ms,深度6则可能偶尔超过1秒。在人机对战中,1秒内的等待可以接受,但如果玩家级别是“困难”,深度可以设5,配合后续的迭代加深策略会更稳。

3.5 界面细节:落子动画与状态提示

黑白棋其实不需要复杂的动画,但加一点反馈能让程序质感完全不一样。

我做了两个小功能:

  1. 落子翻转动画:新落下的棋子先以半透明状态显示,然后用QPropertyAnimation控制一个从0到1的量flipProgress,在paintEvent()中依据该值把棋子宽度从100%缩到10%再扩展回来,实现“翻转”的视觉错觉。实际只用了几行代码,但效果非常像样。

  2. 状态栏提示:在MainWindow底部用QStatusBar显示“轮到黑棋/白棋”、“黑棋10 : 白棋22”、“游戏结束,白棋胜”等信息。这不仅是给玩家的反馈,调试时也能帮你确认AI的走子切换是否正常。

这两个功能都不难,但会让作品完成度上一个台阶。我的经验是:棋类程序的UI不用炫酷,把“状态可见性”做好,玩家就会觉得你用心的程度非常高

4. 常见问题与排查技巧实录

这里把我在开发中真实遇到的几个坑集中写一下,按出现频率排序,每一个都附上解决思路,希望对你有用。事实上,下面这些坑几乎每个刚从“CLI程序”转到“Qt桌面程序”的C++学习者都会碰到至少两三个。

4.1 Windows下启动报错:“no Qt platform plugin could be initialized”

这个报错我在新环境运行程序时遇到过一次,原因是程序找不到Qt平台插件(比如qwindows.dll)。常见的触发场景是:用Qt Creator直接运行时没问题,但把debugrelease目录下的exe单独拷走运行时就报错。

解决方案有两个:

  1. 在Qt Creator里用Release模式编译,然后在安装Qt的目录下找到windeployqt.exe,对目标exe执行:
windeployqt.exe D:\build\reversi\release\reversi.exe

它会自动把需要的Qt DLL和插件复制到exe旁边,发布时把这个目录打包即可。

  1. 如果还是报错,检查环境变量PATH里是否包含Qt的bin路径。但注意:真正发布给别人用的程序,应该采用第一种方式,而不是要求人家配置环境变量

4.2 点击棋盘没有反应,程序却也没崩溃

这是我刚写完界面时的头号问题。排查思路是:先看控制台有没有qDebug()输出,再在mousePressEvent()里加一行qDebug() << row << col;,确认坐标转换是否正确。

后来我发现,问题出在事件被拦截了。如果BoardWidget的父窗口上有其他子控件(比如一个透明的QLabel盖在棋盘上方),鼠标事件会被那个控件吃掉,BoardWidget永远接收不到点击。解决方法是把棋盘控件raise()到最上层,或者在UI设计时注意控件的叠加层级。

另外,如果你重写了mousePressEvent()却忘了调用QWidget::mousePressEvent(event),也可能会导致后续事件处理不正常。虽然桌面程序里影响不大,但养成正确的事件处理习惯总是好的。

4.3 AI落子速度不稳定,时快时慢

理论上Alpha-Beta剪枝的搜索耗时应该和局面复杂度强相关。如果你发现某些局面的搜索时间异常长,很可能是因为评估函数里调用了大量不必要的重复计算,比如每次evaluate()时都重新计算所有稳定子,而稳定子本可以在每次落子后增量更新。

我的优化思路是:

  • Board类里维护一个stableCache数组,记录每个位置是否为稳定子。
  • 每次applyMove()后,只更新受影响的区域(比如该行、该列和两条对角线),而不是全盘重算。
  • 这样中盘阶段搜索速度能提升20%~40%,体感非常明显。

如果你不想碰稳定子这么复杂的概念,还有一个性价比很高的优化:走法排序。优先搜索角、边以及靠近中心的位置,Alpha-Beta剪枝的效率会大幅提升。测试里,同样的深度4,简单排序前后的耗时差距能到2倍以上。

4.4 中文显示乱码

在Qt 5里,中文字符串一般只要源文件保存为UTF-8编码,编译时加上/utf-8参数(MSVC),或者统一使用QStringLiteral,基本不会乱码。我在Windows + MinGW环境下没遇到问题,但MSVC编译器有时不认UTF-8无BOM的源文件,导致中文字符串变成乱码。

解决办法是:在.pro文件里加一行:

QMAKE_CXXFLAGS += /utf-8

另外,代码里不要用std::string存储界面显示文本,一律用QString,避免编码转换引入额外问题。

4.5 游戏无法判定“双方都无合法走法”

这是我上面提过的逻辑Bug:只检查了当前玩家是否无合法走法,就判定游戏结束,但没检查对手是否也无合法走法。

正确顺序是:

  1. 当前玩家无合法走法 → 切换玩家。
  2. 切换后的玩家也无合法走法 → 游戏结束。
  3. 否则继续游戏,由新玩家行动。

把这段逻辑单独抽成一个checkGameEnd()函数,每次走完子后调用,比散落在各个分支里要清晰得多。

bool GameController::checkAndHandlePass() { if (board.hasAnyLegalMove(currentPlayer)) return false; // 当前玩家无法行动,pass currentPlayer = opposite(currentPlayer); if (board.hasAnyLegalMove(currentPlayer)) { emit infoMessage("对方无合法走法,已跳过"); return false; } // 双方都无法行动,结束 currentPlayer = EMPTY; // 标记游戏结束 gameOver = true; return true; }

5. 后期的扩展方向与个人建议

如果你按上面这套写完,已经是一个界面美观、人机可战的完整黑白棋了。但我的经验是,一个作品最能体现“工程能力”的地方,往往不是第一个能跑的版本,而是后续一步步打磨的过程。

几个我试过且值得做的扩展方向:

  • AI难度分级(简单/普通/困难):通过控制搜索深度和评估函数的噪声来实现。简单模式固定深度1~2,困难模式深度5~6,实测难度差异非常明显。
  • 悔棋与复盘:这里就体现出“逻辑层与界面层分离”的好处了——你只需要在GameController里维护一个历史栈QStack<BoardState>,落子前压栈,悔棋时出栈重绘就行。
  • 保存/加载对局:用QJsonDocument把当前棋盘状态、当前玩家、历史记录写入JSON文件,下次启动时恢复。QT自带的JSON支持让这个功能半小时内就能搞定。
  • 网络对战(进阶):使用QTcpSocketQUdpSocket,一台机器创建服务器,另一台连接,之后同步的只是“坐标”,逻辑层完全复用。

最后分享我个人的一个体会:很多人学C++会觉得“语法都会,就是写不出完整的项目”。黑白棋这玩意儿的魅力就在于——它规则极其简单,算法深度却足够打磨两周,同时在自动机、界面编程、算法设计上都给了你充分的练习空间。我做完这一版后,再回头看那些“C++八股文”里的虚函数、多态、智能指针,突然就通透了——因为你真的在代码里用到了它们,而不是在面试题里背到它们。

如果你也在做类似的棋类项目,卡在某个环节上,不妨照着我的方案先跑通一遍,再对比自己的设计看哪里可以改进。棋类游戏最有趣的从来不是“赢”,而是你在不断和“昨天的自己”对弈。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询