简介:一份基于C++ Qt开发的Surakarta(苏拉卡尔塔棋)游戏完整源码包,面向有一定Qt基础、希望学习网络对战与棋类AI实现的开发者。项目采用客户端/服务器分离架构,支持跨平台联机、AI托管、移动路径提示、超时判断、旋吃动画及8/10路棋盘切换,并以信号槽机制完成交互,代码统一驼峰命名并带有注释,适合作为课程设计或毕业设计的参考。压缩包共33个文件,涵盖C++源码(.h/.cpp)、Qt界面文件(.ui)、图片资源(.png/.jpg)及项目说明文档(.md),整体仅1.48MB,结构精简便于快速阅读。目前已有51人学习下载,其中日志系统、濒死提示等细节设计能为棋类逻辑与UI结合提供直观范例,值得动手编译运行体验。
1. C++ Qt实现的Surakarta棋:不是跳棋,是带环线吃子的状态机游戏
很多人拿到一个“(源码)基于C++ Qt的Surakarta游戏.zip”,第一反应是又一个小游戏练手包。实际上Surakarta(苏拉卡尔塔棋)是印尼传统棋类,棋盘6×6、双方各12个棋子,表面上和跳棋差不多;但四角的马蹄形环线让吃子规则完全变了——棋子必须绕环线再走直线才能吃子,普通跳法根本吃不到。用C++ Qt来实现它,最花时间的不是画棋盘和棋子,而是把环线几何转成规则判断,再把规则转成可撤销的移动命令。下面直接按棋盘建模、Qt绘图、规则引擎、AI搜索、排错到进阶的顺序展开,给的是能直接抄进Qt工程的代码和参数,希望帮你把这个小项目做成自己能跑、能存盘、能加AI的完整作品。
2. 棋盘建模与Qt绘图:先把6×6交叉点和四角环线画对
这一章先解决“怎么让棋盘看起来像个Surakarta,并且让每个棋子的点击位置和逻辑位置对得上”。很多新手一上来就写paintEvent画线,画起来爽,后面做点击和规则时就乱了。我一般用QGraphicsScene加QGraphicsView这套组合:场景里的每个像素位置都由view的坐标系换算,棋盘逻辑坐标用另外一个结构保存,两边通过一个纯函数互相转换。这样做的第一好处是,棋盘尺寸、窗口缩放、设备像素比都交给Qt处理;第二好处是后面想做动画、高亮、拖拽,都能直接在item层上加。
2.1 棋盘状态的数据结构:坐标、棋子与回合字段
优先把棋盘建模独立于界面,因为规则引擎、AI、存档都要依赖同一份状态。常见做法是用一个Board类封装一切,界面层只通过它的公共接口访问。下面这份头文件是一个最精简但能跑通的状态定义:
// board.h #ifndef BOARD_H #define BOARD_H #include <QPoint> #include <QVector> class Board { public: static constexpr int GRID_N = 6; // 棋盘是 6x6 交叉点 static constexpr int BLACK = 1; static constexpr int WHITE = -1; static constexpr int EMPTY = 0; Board() { reset(); } void reset() { m_grid = QVector<QVector<int>>(GRID_N, QVector<int>(GRID_N, EMPTY)); // 黑方放在下方两行(y=4,5),白方放在上方两行(y=0,1) for (int y = 4; y < GRID_N; ++y) for (int x = 0; x < GRID_N; ++x) m_grid[y][x] = BLACK; for (int y = 0; y <= 1; ++y) for (int x = 0; x < GRID_N; ++x) m_grid[y][x] = WHITE; m_current = BLACK; m_history.clear(); } bool inBounds(const QPoint &p) const { return p.x() >= 0 && p.x() < GRID_N && p.y() >= 0 && p.y() < GRID_N; } int at(const QPoint &p) const { return inBounds(p) ? m_grid[p.y()][p.x()] : EMPTY; } void setAt(const QPoint &p, int value) { if (inBounds(p)) m_grid[p.y()][p.x()] = value; } int currentPlayer() const { return m_current; } void switchPlayer() { m_current = (m_current == BLACK) ? WHITE : BLACK; } private: QVector<QVector<int>> m_grid; // m_grid[y][x],y 是行,x 是列 int m_current = BLACK; QVector<Move> m_history; // 后续做撤销和回放用 }; #endif // BOARD_H这里有几个设计习惯值得保留:一是m_grid[y][x]的行列顺序,在整个项目中统一,否则后面坐标换算会来回踩坑;二是不直接暴露m_grid,所有读写都经过at/setAt,这样以后要改成自定义类型(比如棋子带状态)只改这两处;三是reset()里明确初始摆放,Surakarta标准开局就是双方各两排棋子,不用手工输入。为什么初始上下相反?因为视图里白方通常在上面,黑方在下面,和象棋谱的习惯一致。
2.2 用QGraphicsScene绘制网格、棋子和环线
数据结构定好后,剩下来就是让用户看得见。这里不用QWidget的paintEvent,而是用QGraphicsScene,因为棋类游戏的图元数量很少,Scene自带的索引和事件过滤能省掉大量自己写的重绘逻辑。下面这段是BoardView构造函数的核心部分:
// boardview.cpp void BoardView::buildBoard() { scene()->clear(); const int cell = 80; // 每个交叉点之间的像素距离 const int margin = 60; // 棋盘外白边,给环线留空间 m_origin = QPointF(margin, margin); m_cellSize = cell; // 画 6 横 6 纵的网格线,注意只画到边界交叉点 for (int i = 0; i < Board::GRID_N; ++i) { scene()->addLine(margin, margin + i * cell, margin + (Board::GRID_N - 1) * cell, margin + i * cell, QPen(QColor(60, 60, 60), 1.5)); scene()->addLine(margin + i * cell, margin, margin + i * cell, margin + (Board::GRID_N - 1) * cell, QPen(QColor(60, 60, 60), 1.5)); } // 画四角环线,环线几何来自 Board::loopPaths() const QVector<LoopPath> loops = Board::loopPaths(); for (const LoopPath &loop : loops) { QPainterPath path; path.moveTo(boardToScene(loop.entry)); for (const QPoint &p : loop.path) path.lineTo(boardToScene(p)); path.lineTo(boardToScene(loop.exit)); QGraphicsPathItem *item = scene()->addPath( path, QPen(QColor(200, 120, 60), 3), QBrush(Qt::NoBrush)); item->setZValue(-1); // 环线压在网格线下面,不影响点击 } }boardToScene是坐标换算的核心函数,写法很简单:
QPointF BoardView::boardToScene(const QPoint &boardPos) const { return QPointF(m_origin.x() + boardPos.x() * m_cellSize, m_origin.y() + boardPos.y() * m_cellSize); } QPoint BoardView::sceneToBoard(const QPointF &scenePos) const { return QPoint(qRound((scenePos.x() - m_origin.x()) / m_cellSize), qRound((scenePos.y() - m_origin.y()) / m_cellSize)); }参数说明:cell=80时棋盘实际大小是5×80=400像素,加上两边margin共520像素,这个尺寸在1000×700的窗口里正好;如果窗口更大,可以改成根据viewport的高度动态计算cell = (height - 2*margin) / 5。margin=60不是随便定的,因为四条环线要从边界点向外或者向内绕,留够60像素环线才不显得拥挤。如果你打开源码包发现环线会跑到窗口外,多半是margin给小了。
接下来是棋子的绘制与更新。我建议把棋子放到updatePieces()里统一管理,而不是散落在buildBoard()中,因为每次走子后只刷新棋子,不要让整张棋盘重画:
void BoardView::updatePieces() { // 清理旧棋子(zValue > 0 的都是棋子图元) QList<QGraphicsItem*> oldPieces; for (auto *item : items()) if (item->zValue() > 0) oldPieces << item; qDeleteAll(oldPieces); const qreal radius = m_cellSize * 0.38; for (int y = 0; y < Board::GRID_N; ++y) { for (int x = 0; x < Board::GRID_N; ++x) { QPoint p(x, y); int color = board.at(p); if (color == Board::EMPTY) continue; QColor brushColor = (color == Board::BLACK) ? Qt::black : Qt::white; auto *ellipse = scene()->addEllipse(-radius, -radius, radius * 2, radius * 2, QPen(QColor(40, 40, 40), 2), QBrush(brushColor)); ellipse->setPos(boardToScene(p)); ellipse->setZValue(1); // 把棋盘坐标存进item data,点击命中后直接取出 ellipse->setData(Qt::UserRole, QVariant(p)); } } }这段代码里半径用m_cellSize乘以0.38,是因为棋子直径不能超过格子间距,否则相邻棋子会叠在一起;0.38是实测后比较舒适的比例,你可以在0.32到0.42之间微调。setData把QPoint存进图元,点击时用item->data(Qt::UserRole).toPoint()就能拿到棋盘坐标,省掉一次反算。这个做法在Qt界面设计里很常见,比全局维护一个“棋子item到坐标”的映射要更不容易错。
2.3 环线几何:把四条马蹄环拆成离散点,既画图又判路
Surakarta最容易被忽视的就是四角环线。标准规则里,棋子要经过至少一条环线才能产生吃子路径;这个“环线”并不是装饰,它必须也参与路径连通性判断。所以我在项目里把每条环线定义成一个LoopPath结构,由入口、出口和中间点组成:
struct LoopPath { QPoint entry; // 从哪条边进入环线 QPoint exit; // 环线另一端的出口 QVector<QPoint> path; // 中间转折点,画图和寻路都用它 }; QVector<LoopPath> Board::loopPaths() { return { { {2, 0}, {0, 2}, { {1, 0}, {1, 1}, {0, 1} } }, // 左上 { {3, 0}, {5, 3}, { {4, 0}, {5, 1}, {5, 2} } }, // 右上 { {0, 3}, {3, 5}, { {0, 4}, {1, 5}, {2, 5} } }, // 左下 { {5, 2}, {2, 5}, { {4, 5}, {5, 4}, {5, 3} } } // 右下 }; }为什么用离散点而不是用QPainterPath::arcTo画曲线?因为规则引擎需要在环路径上判断“有没有障碍、走到哪里吃子”,如果存的是曲线方程,判断逻辑会变成黑匣子;改成离散点之后,路径就是一系列线段,逐段检查isClear就能完成。绘制效果上,三个转折点加一条斜线视觉上接近马蹄形,玩家不会觉得别扭。如果你想让环线更圆,可以自己去Board::loopPaths()里加插值点,规则引擎会自动适配,这就是独立数据结构的回报。
注意,这四个环的入口和出口方向要能对上exitDirection()的判定:左上环从上方(2,0)入,从左边(0,2)出;右上环从上方(3,0)入,从右边(5,3)出;左下环从左边(0,3)入,从下方(3,5)出;右下环从右边(5,2)入,从下方(2,5)出。每个环本质上是让棋子横竖方向互换,所以吃子路径才会出现“先横走,绕环后竖走”。
3. 规则引擎:走子、吃子与回合状态机,先把Surakarta的环线吃子讲透
有了棋盘数据和绘图,接下来是整个项目的核心:合法走法生成。Surakarta的规则在代码里其实只有两种走法——普通走子和环线吃子。普通走子可以理解为“只能沿横竖线移动,不能跳过任意棋子”;吃子则是“先沿直线走到环入口,绕环到出口,再沿新方向的直线吃到第一个对方棋子”。这一章必须写得非常严谨,因为AI搜索和撤销/回放都是建立在这组生成函数之上的。
3.1 普通走子生成:直线可达与“不能跳过棋子”
Surakarta的普通走法和国际象棋的车类似:横着或竖着走任意格,但路径上不能有任何棋子,目标格也必须为空。因为只有这样才能保证吃子必须依赖环线。实现时,要分别向四个方向扩展:
void Board::generateNormalMoves(int player, QVector<Move> &moves) const { const QPoint dirs[4] = { {1, 0}, {-1, 0}, {0, 1}, {0, -1} }; for (int y = 0; y < GRID_N; ++y) { for (int x = 0; x < GRID_N; ++x) { QPoint from(x, y); if (at(from) != player) continue; for (const QPoint &d : dirs) { QPoint p = from + d; while (inBounds(p)) { if (at(p) != EMPTY) break; // 遇到棋子就停止,不能跳过 moves.append({from, p, QPoint()}); p += d; } } } } }Move结构体里的captured字段在普通走法中留空。判断时注意,扩展条件是at(p)==EMPTY,所以不能走到有己方棋子的格子上,也不能跳到敌方棋子后面。这一步逻辑简单,但却是整个规则的地基,因为如果这里允许跳子,后面的环线吃子就完全失效了。生成完普通走法后,整个走法集还要加上吃子走法,所以generateAllMoves返回两个函数的并集。
3.2 吃子走法生成:先过环线,再沿直线吃第一个敌子
吃子是Surakarta的核心,也是整个项目里最容易写出Bug的地方。标准规则我简化成一句话:棋子沿直线走到环线入口,沿环线绕到出口,再从出口沿新方向的直线延伸,遇到的第一个敌方棋子即被吃掉,我方棋子落到那个格子上。这里的“环线”不能跳过,整段路径上都不能经过其他棋子。
生成吃子走法的代码我建议写成下面这样,把方向计算封装成函数,避免重复:
QPoint Board::exitDirection(const QPoint &exitPoint) const { if (exitPoint.y() == 0) return {0, 1}; // 上方出口,向下走 if (exitPoint.y() == GRID_N - 1) return {0, -1}; // 下方出口,向上走 if (exitPoint.x() == 0) return {1, 0}; // 左侧出口,向右走 if (exitPoint.x() == GRID_N - 1) return {-1, 0}; // 右侧出口,向左走 return {}; } void Board::generateCaptureMoves(int player, QVector<Move> &moves) const { const QVector<LoopPath> loops = loopPaths(); const QPoint dirs[4] = {{1,0},{-1,0},{0,1},{0,-1}}; for (int y = 0; y < GRID_N; ++y) { for (int x = 0; x < GRID_N; ++x) { QPoint from(x, y); if (at(from) != player) continue; for (const QPoint &d1 : dirs) { QPoint cur = from; while (inBounds(cur)) { bool entered = false; for (const LoopPath &loop : loops) { if (cur != loop.entry) continue; // 入口段:from -> entry 必须畅通,且入口格为空 if (!isLineClear(from, loop.entry, player)) break; // 环线段:环上的每个点都不能有棋子 if (!isLoopClear(loop)) break; // 出口段:沿新方向延伸,遇到第一个敌方棋子就吃 QPoint d2 = exitDirection(loop.exit); QPoint t = loop.exit + d2; while (inBounds(t)) { int target = at(t); if (target == player) break; // 自己的棋子挡住 if (target != EMPTY) { moves.append({from, t, t}); // captured = t break; } t += d2; } entered = true; break; } if (entered) break; cur += d1; } } } } }这里的isLineClear和isLoopClear是路径检查函数。isLineClear负责把从from到entry之间的所有格子(不含起点和入口)检查一遍,不允许出现任何棋子,包括己方;入口点本身也必须为空,否则你没法站上去。isLoopClear需要遍历loop.path,再包括入口和出口,逐段判断是否为空。这两段逻辑容易漏,尤其是环线里经过的中间点,如果某一点放了棋子,装备再好的棋也得停下来。
值得注意的是,出口方向d2的判定依赖出口在哪条边,这是Surakarta棋盘环线的几何特性:进入环时你走在水平线或垂直线,出环后你走的是与原来垂直的方向。四个环对应了四个方向的“换向”。如果你下载的源码包里的环线几何和我这里不一样,记得同步修改exitDirection,否则所有吃子都会错位。
3.3 将规则接入Controller:从点击到合法落子
规则引擎生成的是候选Move列表,界面层要根据玩家的点击找出有没有匹配的候选。一般我不直接拿鼠标点到的位置去比对规则,而是把整个走法集合存下来,每次点击先在集合里搜索:
void GameController::onCellClicked(const QPoint &boardPos) { if (!board.inBounds(boardPos)) return; // 第一步:选子 if (m_selected.isEmpty()) { if (board.at(boardPos) == board.currentPlayer()) m_selected = boardPos; return; } // 第二步:尝试移动 Move target{ m_selected, boardPos, QPoint() }; const QVector<Move> moves = board.generateAllMoves(board.currentPlayer()); for (const Move &mv : moves) { if (mv.from == m_selected && mv.to == boardPos) { board.apply(mv); m_selected = QPoint(); emit boardChanged(); return; } } // 没有匹配的合法走法:如果点的是自己的另一个棋子,换选子 if (board.at(boardPos) == board.currentPlayer()) { m_selected = boardPos; } }这里我把“合法走法集合”当作一份可枚举的清单,而不是在点击时写一堆if-else。因为Surakarta的吃子路径不止一条,同一个起点可能对应多个不同出口方向的吃法,手工判断必然漏。apply是状态变更的唯一入口,它会记录当前棋盘到m_history,然后更新currentPlayer;undo则反向操作,后面AI搜索和回放都会依赖它。注意,不要允许通过直接修改Board::m_grid来走棋,否则界面、AI、存档很快就会各执一词。
4. 人机对战AI:从贪心到Minimax,两步搞定
棋类项目有了规则引擎后,下一步自然是加个AI。不需要很强,能挡住新手就行。Surakarta状态空间不大,6×6棋盘、每位玩家最多12子,搜索深度4到5就能跑出像样的棋。我推荐先写评估函数,再套Minimax加Alpha-Beta剪枝,最后用Qt并发把它放到后台线程,避免界面卡死。
4.1 评估函数:让AI知道什么是“好局面”
AI第一步需要一个打分函数。Surakarta的目标是吃掉对方所有棋子,残局阶段棋子数量最重要,中局阶段位置也有价值。一个简单但有效的评价是:
int Board::evaluate(int aiPlayer) const { int score = 0; static const QPoint center(2, 2); for (int y = 0; y < GRID_N; ++y) { for (int x = 0; x < GRID_N; ++x) { QPoint p(x, y); int piece = at(p); if (piece == EMPTY) continue; int sign = (piece == aiPlayer) ? 1 : -1; score += sign * 10; // 棋子本身价值 10 分 int dist = qMax(qAbs(x - center.x()), qAbs(y - center.y())); score += sign * (3 - dist); // 越靠中心加越多,最高 +3 } } return score; }参数说明:10是棋子基础分,吃一个子就等于赚20分;中心加成最高3,最低-2,用来诱导AI往棋盘中部发展。实际调参时,基础分和中心分不要差距过大,否则AI会为了占中心而拒绝吃子,看起来很傻。如果你希望AI更激进,把基础分调到20即可。这个评估函数没有考虑环线控制,因为真正靠近环线的点价值已经在“中心加成”里大体体现,暂时够用。
4.2 Minimax搜索:深度4加Alpha-Beta剪枝就够了
Surakarta每回合走法数量在几十个量级,6×6棋盘深度4的搜索量也是万级别,完全可以在毫秒级跑完。核心搜索函数我习惯写成下面这样:
struct SearchResult { int score; Move bestMove; }; int Board::minimax(int depth, int alpha, int beta, int player, int aiPlayer) { QVector<Move> moves = generateAllMoves(player); if (depth <= 0 || moves.isEmpty()) return evaluate(aiPlayer); if (player == aiPlayer) { int best = -1000000; for (const Move &mv : moves) { apply(mv); best = qMax(best, minimax(depth - 1, alpha, beta, -player, aiPlayer)); undo(mv); alpha = qMax(alpha, best); if (beta <= alpha) break; // 剪枝 } return best; } else { int best = 1000000; for (const Move &mv : moves) { apply(mv); best = qMin(best, minimax(depth - 1, alpha, beta, -player, aiPlayer)); undo(mv); beta = qMin(beta, best); if (beta <= alpha) break; } return best; } }要点是apply/undo必须对称,被吃掉的棋子也要在undo里恢复。所以我给Move增加了一个captured字段,apply时如果captured非空,就先把目标格子的棋子存进一个栈,再把它设为移动棋子的值。Alpha-Beta剪枝的效果在这类游戏中非常明显:不剪枝深度4可能要算500万次,剪枝后往往只有几万次。第一次写剪枝最容易犯的错是alpha/beta更新方写反,导致搜索返回随机值。调试时我会在minimax入口打印depth和前几路走法,用两个固定局面验证剪枝正确性。
在findBestMove里,需要额外维护一个全局bestMove变量。每层alpha更新后,如果当前走法的分数更好就替换:
Move Board::findBestMove(int depth, int aiPlayer) { Move bestMove; int bestScore = -INF; QVector<Move> moves = generateAllMoves(aiPlayer); for (const Move &mv : moves) { apply(mv); int score = minimax(depth - 1, -INF, INF, -aiPlayer, aiPlayer); undo(mv); if (score > bestScore) { bestScore = score; bestMove = mv; } } return bestMove; }这里-INF和INF建议用std::numeric_limits<int>::min()/max(),不要写死-1000000,否则深度太大时会遇到边界问题。
4.3 用QtConcurrent::run跑搜索,别让界面转圈
AI搜索虽然不算慢,但深度设到5、6或开局分支多时,照样会卡UI。Qt里最省事的方案是把搜索丢到后台线程,用QFutureWatcher接收结果。注意不要直接把Board对象在线程里操作,因为主线程可能正在读它:
#include <QtConcurrent/QtConcurrent> #include <QFutureWatcher> class GameController : public QObject { Q_OBJECT public: void startAiTurn() { Board snapshot = board; // 拷贝一份当前棋盘,避免线程竞争 QFuture<Move> future = QtConcurrent::run([snapshot, this]() { // 在快照上搜索,返回最佳走法 return snapshot.findBestMove(4, snapshot.currentPlayer()); }); watcher.setFuture(future); } private slots: void onAiFinished() { Move best = watcher.result(); board.apply(best); emit boardChanged(); } };Board的拷贝是轻量的,因为内部只是6×6的int矩阵。用snapshot替代锁,是一个我强烈推荐的“后悔药”。如果你不小心直接让UI线程等future.result(),窗口会直接卡死,在macOS上还会出现彩虹圈转个不停。还有一点,搜索期间要屏蔽玩家点击,否则一边走子一边搜索,局面会错乱。可以在开始搜索时设一个m_aiThinking标志,点击事件里判断并直接return。
5. 避坑/排查:Qt棋类项目最常见的5个翻车现场
这些坑不是理论推演,而是我在做类似项目时实际遇到过的。每一条都按现象、原因、解决写出来,可以当一份排查手册用。
5.1 点击棋子总是偏一格,选中高亮和鼠标位置对不上
现象:鼠标点在棋子中心,选中高亮却出现在右下方的交叉点;或者点击靠近顶部的行会误选下一行。
原因:我在sceneToBoard里用了qRound((scenePos.x() - m_origin.x()) / m_cellSize),但如果环线绘制时margin和m_origin不相等,或者scenePos是从视图viewport坐标直接转的(没经过mapToScene),就会整体偏移半格。
解决:统一使用BoardView::mapToScene(event->pos())先拿到scene坐标,再调sceneToBoard。并且把m_origin设为一个固定值,不随抗锯齿设置变化。调试时,我通常在mousePressEvent里临时打印scene坐标和换算后的棋盘坐标,一眼就能看出偏差。
5.2 吃子时把环线路径上的“自己的棋子”也吃掉了
现象:AI生成了一种“隔着我方棋子吃掉对方”的走法,人工手搓也复现不出。
原因:isLineClear检查是从from到entry,但随后沿环线到出口这一段用了独立的isLoopClear,两个函数之间没有做联合检查,导致环路径上的己方棋子没有被真正视为阻碍。我早先为了省事,只检查了入口点是否为空,没遍历loop.path。
解决:在generateCaptureMoves里,凡是吃子走法,必须把“入口段 + 环线段 + 出口段”三段路径上的所有非目标棋子都视为障碍,任何一段遇到己方棋子都直接放弃该路径。写完后,我用一个黑盒测试:在环线中间放一颗自己棋子,尝试构造吃子,必须生成0个吃子走法。这种测试建议直接用QTest做成单元测试,每次改规则都能跑一遍。
5.3 AI搜索回合界面卡成白屏,甚至触发Qt崩溃
现象:点击“AI先手”之后窗口无响应,过几秒回来说“程序停止工作”。
原因:搜索放在UI线程,minimax的递归在开局会展开几万甚至几十万个节点,阻塞了Qt事件循环;如果搜索函数里访问了被UI同时修改的m_grid,还会产生数据竞争,造成崩溃。
解决:把搜索迁移到QtConcurrent::run里,并且传一份Board快照而不是共享指针。搜索结束后通过信号回主线程执行apply。还有一点,qDebug在搜索循环里不要写,否则大量字符串输出会拖慢好几倍。如果你用的是Qt Creator,勾选“线程安全”相关警告就能看到不少问题。
5.4 存档再读档,棋子位置全部错乱
现象:第一次保存正常,关掉程序再打开,黑白双方棋子的位置明显不对,甚至出现一个格子里有两颗棋子。
原因:我用QDataStream写棋盘时直接stream << m_grid,但实际上QVector<QVector<int>>的序列化会把每个内层vector都写成一个长度前缀,而读取时如果棋盘大小不是从存档读取而是写死为6×6,就多读或少读了一段数据。
解决:保存时先写入魔数和版本号、棋盘大小、当前玩家,再逐行写棋子;读取时严格按相同顺序。示例如下:
QDataStream in(&file); in.setVersion(QDataStream::Qt_5_15); int version = 1; in << version << Board::GRID_N << board.currentPlayer(); for (int y = 0; y < Board::GRID_N; ++y) for (int x = 0; x < Board::GRID_N; ++x) in << board.at({x, y}); // 读取时逆操作,先读版本和大小,再循环读入如果你是第一次用QDataStream,最好打印一下in.status(),常见错误是版本不匹配导致静默失败。QDataStream的版本号建议统一设为Qt_5_15或更高,否则在不同Qt版本间读档会对不上。
5.5 窗口最大化之后棋盘挤成一坨,环线都重叠了
现象:把窗口拖大,棋盘没有跟着变大,四角环线和边缘重叠;拖小后又显示出残缺。
原因:BoardView::buildBoard里用固定cell=80和margin=60,没有在resizeEvent里重新计算。Qt不会自动缩放你手动添加的QGraphicsItem。
解决:重写BoardView::resizeEvent,根据viewport()->size()计算新cell并重绘:
void BoardView::resizeEvent(QResizeEvent *event) { QGraphicsView::resizeEvent(event); int available = qMin(viewport()->width(), viewport()->height()) - 2 * margin; m_cellSize = qMax(40, available / (Board::GRID_N - 1)); rebuildBoard(); }rebuildBoard会清空场景并重新画网格、环线和所有棋子。这样既能自适应窗口,又不把逻辑几何写死在绘图代码里。注意重绘后要重新调用updatePieces(),否则棋子还是旧位置。
以上5个坑,前两个是规则/坐标的“硬伤”,后三个是工程化问题。建议在搭建阶段就把Board和视图层分离,不要图一时方便让两者互相引用,后面能少掉一半麻烦。
6. 进阶:让源码具备产品感的三个技巧——存档、回放与动画
6.1 用QDataStream做存档,给玩家一颗后悔药
有了第5章的序列化思路,存档就只是加一层文件读写。建议每次走子后自动保存到临时文件,供玩家撤销或者回放。给Move结构体增加undoBoard状态也可以,但更省内存的是只记录from、to、captured,回放时重演走法。存档文件名可以用QFileDialog::getSaveFileName,路径处理记得用QFileInfo检查同名覆盖,避免用户误操作。
6.2 回放用定时器队列,动画用QPropertyAnimation
如果再让我做这个项目,我会把“一步棋”封装成MoveCommand,包含起止点、吃子点和动画时长。用一个QSequentialAnimationGroup去播放;播放完再执行Board::apply。这样回放和实盘操作共用同一套命令,代码很干净:
class MoveCommand { public: Move move; int durationMs = 400; }; void GameController::replay(const QVector<MoveCommand> &cmds) { for (const auto &c : cmds) m_animQueue.enqueue(c); processNextCommand(); } void GameController::processNextCommand() { if (m_animQueue.isEmpty()) return; MoveCommand c = m_animQueue.dequeue(); startAnimation(c); // QPropertyAnimation 控制棋子item位置 }技巧说明:动画不要一帧一帧自己写QTimer,用Qt的动画框架会平滑很多,而且自然支持缓动曲线。但注意动画期间必须锁住输入,否则玩家疯狂点击会让队列乱套。动画结束后再发boardChanged()信号,这样UI和逻辑始终同步。
6.3 一点个人习惯
我最早做这个项目时,把环线几何直接写在paintEvent里,导致规则引擎和显示耦合,后来每次调环线形状都要连带改搜索逻辑,非常痛苦。后来我把LoopPath抽成独立数据结构、把Board和View分层,整个项目才真正活了。现在你拿到的源码,如果第一眼觉得绕,先别改规则,先把环线数据打出来,跑一遍单元测试,比盯着界面猜要快得多。这个项目做完,你对Qt的坐标系、序列化、多线程都能收获一套血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取