资讯详情

资讯详情

Qt六子棋项目实战:从棋盘建模到悔棋重做的完整开发指南

简介这是一套面向计算机相关专业学生的Qt项目实战源码以六子棋Connect6为题材适合正在准备课程设计、期末大作业或需要项目实战练习的学习者参考。项目结构完整包含主界面与游戏逻辑的分离设计涉及棋盘绘制、落子判定、胜负判断等核心模块并配有图片与音效资源便于直接运行与二次开发。压缩包共30个文件以cpp与h源码文件为主另有ui界面文件、pro工程文件、qrc资源文件、png图片、wav音效及md说明文档整体约16.97MB目录组织清晰方便按模块查阅。目前已有812人学习下载说明该案例在同类大作业中具有一定参考价值。读者可从中获取完整的项目工程结构、界面与逻辑交互的实现思路、资源加载方式以及可复用的代码框架适合作为课程设计模板或Qt入门后的进阶练习素材。1. 六子棋项目为什么成了 Qt 入门的试金石六子棋这个题目看起来像是随手翻到的一个小作业但真正动手写一遍就会发现它几乎把 Qt 桌面开发里最核心的几块拼图全串起来了自定义绘图、鼠标事件、棋盘坐标映射、胜负判定、悔棋栈、界面与逻辑分层。很多初学者学完 QWidget、QPainter、信号槽之后仍然不知道这些零件怎么拼成一个能跑、能玩、能改的完整程序而六子棋恰好是一个体量刚好、边界清晰、又能不断加需求的载体。它不像计算器那样只练布局也不像记事本那样只练文件读写它逼着你同时处理状态、交互和渲染三条线。如果你正在找一个能放进简历、又能真正讲清楚设计思路的 Qt 项目六子棋源码是一个值得逐行拆开看的样本。下面我按实际复现的顺序把棋盘建模、落子交互、胜负判定、悔棋重做和工程组织一层层讲透中间会给出可直接抄用的代码骨架和参数取值。2. 棋盘建模与坐标映射先把数据结构定死2.1 为什么用二维数组而不是 QGraphicsScene六子棋的棋盘是 15×15 的交叉点落子发生在交叉点上不是格子里。很多教程一上来就推荐 QGraphicsScene QGraphicsItem理由是“可扩展、支持动画”。但如果你只是做一个六子棋这套方案会带来三个额外成本一是每个棋子都要维护一个 Item 对象内存和刷新开销成倍增加二是坐标转换要在场景坐标、视图坐标、逻辑坐标之间来回倒三是悔棋时删除 Item 再重建容易出悬空指针。我一般会先用纯 QWidget QPainter 自绘把逻辑层压到一个int board[15][15]上渲染层只负责读这个数组画圆。这样逻辑和界面彻底分离后面加 AI 或者网络对战都不用动绘图代码。数据结构定义如下0 表示空1 表示黑2 表示白// boardmodel.h #pragma once #include array class BoardModel { public: static constexpr int kSize 15; // 15 路棋盘 static constexpr int kEmpty 0; static constexpr int kBlack 1; static constexpr int kWhite 2; BoardModel() { reset(); } void reset() { for (auto row : m_board) row.fill(kEmpty); m_history.clear(); m_current kBlack; } int at(int row, int col) const { return m_board[row][col]; } bool isEmpty(int row, int col) const { return at(row, col) kEmpty; } int currentPlayer() const { return m_current; } // 返回 false 表示该点已有棋子 bool place(int row, int col) { if (!isEmpty(row, col)) return false; m_board[row][col] m_current; m_history.push_back({row, col, m_current}); m_current (m_current kBlack) ? kWhite : kBlack; return true; } bool undo() { if (m_history.empty()) return false; const auto last m_history.back(); m_board[last.row][last.col] kEmpty; m_current last.player; m_history.pop_back(); return true; } struct Move { int row; int col; int player; }; const std::vectorMove history() const { return m_history; } private: std::arraystd::arrayint, kSize, kSize m_board; std::vectorMove m_history; int m_current kBlack; };这段代码里有两个参数值得注意。kSize设成 15 是六子棋的常见规格但如果你要做 13 路或 19 路只需要改这一个常量绘图那边用同一个常量算间距即可。kEmpty/kBlack/kWhite用整型而不是枚举是为了后面接 AI 时方便做位运算和数组索引枚举在 C 里做算术反而要反复 cast。m_history用std::vector而不是QStack是因为悔棋之外你可能还要做“重做”和“导出棋谱”vector 的随机访问和序列化都更顺手。2.2 像素坐标与棋盘坐标的双向映射棋盘绘制区域是一个正方形假设控件宽度为w棋盘有 15 条线那么相邻两条线的间距grid w / (kSize 1)四周各留一格边距。交叉点(row, col)的像素中心坐标是// boardwidget.cpp 片段 QPoint BoardWidget::toPixel(int row, int col) const { const int grid m_side / (BoardModel::kSize 1); const int margin grid; return QPoint(margin col * grid, margin row * grid); } // 鼠标点击反查最近的交叉点 bool BoardWidget::toBoard(const QPoint pos, int row, int col) const { const int grid m_side / (BoardModel::kSize 1); const int margin grid; col qRound((pos.x() - margin) / double(grid)); row qRound((pos.y() - margin) / double(grid)); if (row 0 || row BoardModel::kSize) return false; if (col 0 || col BoardModel::kSize) return false; // 距离交叉点太远则视为无效点击 const QPoint center toPixel(row, col); if ((center - pos).manhattanLength() grid * 0.4) return false; return true; }toBoard里那个0.4的阈值是血泪经验如果不加距离判断玩家点在两个交叉点正中间时qRound会随机偏向一边手感非常“玄学”。加上阈值后点偏了就不落子反而更符合直觉。m_side在resizeEvent里更新为qMin(width(), height())保证棋盘始终是正方形不会因为窗口拉宽而变成椭圆。2.3 绘制顺序与抗锯齿参数paintEvent里的顺序必须是背景 → 网格线 → 星位 → 已有棋子 → 最后一手标记。顺序错了会出现棋子被线穿过、标记被覆盖等问题。关键绘制参数void BoardWidget::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); // 棋子边缘平滑 p.fillRect(rect(), QColor(0xE8, 0xC4, 0x8A)); // 木色底 const int grid m_side / (BoardModel::kSize 1); const int margin grid; p.setPen(QPen(Qt::black, 1)); for (int i 0; i BoardModel::kSize; i) { const int off margin i * grid; p.drawLine(margin, off, margin (BoardModel::kSize - 1) * grid, off); p.drawLine(off, margin, off, margin (BoardModel::kSize - 1) * grid); } const int r grid * 0.42; // 棋子半径0.42 比 0.5 更透气 for (int row 0; row BoardModel::kSize; row) { for (int col 0; col BoardModel::kSize; col) { const int v m_model-at(row, col); if (v BoardModel::kEmpty) continue; const QPoint c toPixel(row, col); p.setBrush(v BoardModel::kBlack ? Qt::black : Qt::white); p.setPen(QPen(Qt::black, 1)); p.drawEllipse(c, r, r); } } }r grid * 0.42是我反复调出来的值。用 0.5 时棋子会紧贴相邻棋子视觉上像糊成一片用 0.35 又显得太小、棋盘空。0.42 在 15 路棋盘上刚好留出一条细缝黑白分明。抗锯齿一定要开否则棋子边缘会有明显锯齿截图发出去显得很业余。3. 落子交互与胜负判定六子连珠怎么判才不漏3.1 鼠标事件里的三个状态检查落子不是简单地把mousePressEvent里的坐标转一下就行必须依次检查三件事是否在有效交叉点、该点是否为空、当前是否允许操作比如 AI 思考中要锁盘。顺序不能反否则会出现“点已有棋子的位置却触发了悔棋”这类串味 bug。void BoardWidget::mousePressEvent(QMouseEvent* e) { if (e-button() ! Qt::LeftButton) return; if (m_locked) return; // AI 思考或动画中锁盘 int row -1, col -1; if (!toBoard(e-pos(), row, col)) return; // 不在交叉点附近 if (!m_model-isEmpty(row, col)) return; // 已有棋子 m_model-place(row, col); m_lastMove {row, col}; update(); // 触发重绘 if (checkWin(row, col, m_model-at(row, col))) { emit gameOver(m_model-at(row, col)); } else { emit turnChanged(m_model-currentPlayer()); } }m_locked这个标志位很容易被忽略。如果你后面加了“落子动画”或者“AI 延迟 300ms 再走”不加锁的话玩家可以在 AI 思考期间连点好几手棋盘状态直接乱掉。我一般会在 AI 开始思考时置m_locked trueAI 落子完成后置回false并在paintEvent里根据这个标志决定是否画“思考中”提示。3.2 六子连珠的四个方向扫描六子棋的胜负判定比五子棋多一个字但逻辑完全一样以刚落下的点为中心沿横、竖、左斜、右斜四个方向分别向正负两侧统计同色连续棋子数任意方向达到 6 即胜。这里最容易翻车的地方是边界处理——数组越界不会崩溃但会读到脏数据导致误判。bool BoardModel::checkWin(int row, int col, int player) const { static const int dr[4] {0, 1, 1, 1}; static const int dc[4] {1, 0, 1, -1}; for (int d 0; d 4; d) { int count 1; // 包含当前这颗 // 正方向 for (int step 1; step 6; step) { int r row dr[d] * step; int c col dc[d] * step; if (r 0 || r kSize || c 0 || c kSize) break; if (m_board[r][c] ! player) break; count; } // 负方向 for (int step 1; step 6; step) { int r row - dr[d] * step; int c col - dc[d] * step; if (r 0 || r kSize || c 0 || c kSize) break; if (m_board[r][c] ! player) break; count; } if (count 6) return true; } return false; }dr/dc两个数组把四个方向编码成向量比写四段重复的 for 循环清爽得多。step 6而不是 kSize是因为一旦某个方向连续超过 6 颗第 7 颗开始就没必要再数了提前 break 能省一点计算。注意count初始值是 1代表当前落下的这颗本身如果写成 0 就会永远差一颗这是新手最常见的 off-by-one。3.3 胜负判定与界面反馈的衔接判定函数返回 true 之后不要直接在BoardModel里弹窗模型层不应该依赖任何 UI 类。正确做法是通过信号把结果抛出去由主窗口决定是弹 QMessageBox 还是画一条高亮线。信号定义// boardwidget.h signals: void gameOver(int winner); // 1 黑胜2 白胜 void turnChanged(int currentPlayer);主窗口里连接connect(m_board, BoardWidget::gameOver, this, [this](int winner) { const QString name (winner BoardModel::kBlack) ? 黑方 : 白方; QMessageBox::information(this, 对局结束, name 六子连珠获胜); m_board-setLocked(true); // 结束后锁盘防止继续落子 });把setLocked(true)放在这里而不是模型里是为了让“再来一局”按钮能重新解锁。如果你把锁死在模型内部重置时还要额外写一个解锁接口反而多一层耦合。4. 悔棋、重做与工程组织让项目能继续长大4.1 用命令栈实现悔棋和重做悔棋如果只是简单地把最后一颗棋子抹掉那“重做”就没法实现。正确做法是维护两个栈undoStack存已落子的操作redoStack存被撤销的操作。落子时清空redoStack悔棋时把操作从undoStack移到redoStack重做时反向移动。// 在 BoardModel 基础上扩展 void BoardModel::place(int row, int col) { if (!isEmpty(row, col)) return; m_board[row][col] m_current; m_undoStack.push_back({row, col, m_current}); m_redoStack.clear(); // 新落子后重做历史作废 m_current (m_current kBlack) ? kWhite : kBlack; } bool BoardModel::undo() { if (m_undoStack.empty()) return false; Move last m_undoStack.back(); m_undoStack.pop_back(); m_board[last.row][last.col] kEmpty; m_current last.player; m_redoStack.push_back(last); return true; } bool BoardModel::redo() { if (m_redoStack.empty()) return false; Move last m_redoStack.back(); m_redoStack.pop_back(); m_board[last.row][last.col] last.player; m_current (last.player kBlack) ? kWhite : kBlack; m_undoStack.push_back(last); return true; }这里有个细节redo里切换当前玩家时用的是last.player的对手而不是简单取反。因为如果将来加入“让子”或者“AI 执黑”这类规则简单取反会出错。用last.player推导下一手是谁逻辑上更稳。4.2 界面与逻辑分层的目录结构一个能继续长大的 Qt 项目目录不能全堆在根目录。我一般会这样组织sixchess/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── boardmodel.h / boardmodel.cpp // 纯逻辑不依赖 Qt Widgets │ ├── boardwidget.h / boardwidget.cpp // 绘图与鼠标交互 │ ├── mainwindow.h / mainwindow.cpp // 菜单、工具栏、状态栏 │ └── aiplayer.h / aiplayer.cpp // 预留的 AI 接口 └── resources/ └── icons.qrcboardmodel只依赖array和vector不 include 任何 Qt 头文件。这样做的好处是单元测试可以直接编译模型层不需要起 QApplication。boardwidget依赖 Qt Widgets但只通过信号槽和模型通信不直接改模型内部状态。mainwindow负责把两者粘起来并处理菜单动作。CMake 里对应写cmake_minimum_required(VERSION 3.16) project(sixchess LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) add_executable(sixchess src/main.cpp src/boardmodel.cpp src/boardwidget.cpp src/mainwindow.cpp ) target_link_libraries(sixchess PRIVATE Qt6::Widgets)CMAKE_AUTOMOC ON必须开否则带Q_OBJECT的类不会生成 moc 文件链接时会报 undefined reference to vtable。这个报错新手经常遇到翻半天代码找不到问题其实就是 CMake 少了一行。4.3 状态栏与回合提示的低成本实现状态栏不需要复杂设计一个QLabel加statusBar()-addWidget()就够。关键是提示文案要跟着turnChanged信号走connect(m_board, BoardWidget::turnChanged, this, [this](int player) { const QString name (player BoardModel::kBlack) ? 黑方 : 白方; m_turnLabel-setText(当前回合 name); });m_turnLabel在构造函数里创建setMinimumWidth(120)防止文字变化时状态栏抖动。这个抖动问题很烦人不加最小宽度的话每次切换回合状态栏都会轻微跳一下长时间盯着看很累。5. 避坑与排查六子棋源码里最容易翻车的五个点5.1 落子后棋盘不刷新现象点击鼠标后逻辑已经落子但界面没变化要拖动窗口才重绘。原因通常是mousePressEvent里忘了调update()或者paintEvent里读的是旧数据。解决在place成功后立刻update()并确认paintEvent里访问的是m_model的实时状态而不是缓存的副本。如果用了双缓冲检查QPixmap是否在每次重绘前被清空。5.2 悔棋后回合显示错乱现象悔棋之后棋盘上少了一颗子但状态栏还显示原来的回合。原因是悔棋只改了模型没有发turnChanged信号。解决在undo()成功后手动emit turnChanged(m_model-currentPlayer())或者在主窗口里连接一个专门的boardChanged信号统一刷新。我倾向于后者因为重做、重置都需要刷新统一一个信号更省事。5.3 窗口缩放后棋子错位现象拉大窗口后棋子还在原来的像素位置和网格线对不上。原因是toPixel里用的m_side没有在resizeEvent里更新。解决重写resizeEvent把m_side qMin(width(), height())并调用update()。注意m_side要是成员变量不能每次在paintEvent里临时算否则鼠标事件和绘制事件用的基准不一致。5.4 连续快速点击导致重复落子现象手速快的时候同一个交叉点被落了两颗子或者黑白顺序乱了。原因是 Qt 的事件队列在快速点击时会连续投递mousePressEvent而isEmpty检查和place之间没有原子性。解决在mousePressEvent开头加一个if (m_processing) return;处理完再置回 false或者更简单在place内部先检查再写入因为单线程下place本身是原子的问题往往出在检查和使用之间插入了update()导致的间接重入。5.5 编译时报 vtable 未定义现象链接阶段报undefined reference to BoardWidget::staticMetaObject或 vtable 相关错误。原因通常是类里写了Q_OBJECT宏但 CMake 没有开AUTOMOC或者头文件没有被add_executable收录。解决确认CMAKE_AUTOMOC ON并把所有带Q_OBJECT的头文件所在源文件都加进add_executable。如果用的是 qmake确认.pro里有QT widgets且头文件在HEADERS里。6. 从能跑到好用三个提升手感的细节第一个细节是最后一手标记。在paintEvent里如果m_lastMove有效就在那颗棋子上画一个红色小方框或者圆环。代码很短if (m_lastMove.row 0) { const QPoint c toPixel(m_lastMove.row, m_lastMove.col); p.setPen(QPen(Qt::red, 2)); p.setBrush(Qt::NoBrush); p.drawRect(c.x() - r, c.y() - r, r * 2, r * 2); }这个标记能让玩家一眼看到对手刚下在哪里尤其是复盘的时候比来回数棋子高效得多。r就是前面算出的棋子半径方框比棋子略大一圈视觉上不遮挡棋子本身。第二个细节是悬停预览。在mouseMoveEvent里把鼠标位置转成棋盘坐标如果该点为空就画一个半透明的当前色棋子。需要setMouseTracking(true)否则不按键时收不到移动事件。半透明用QColor(0, 0, 0, 80)或QColor(255, 255, 255, 80)透明度 80 左右刚好能看清又不抢眼。这个功能对新手很友好能减少“点错位置”的挫败感。第三个细节是键盘快捷键。CtrlZ悔棋、CtrlY重做、CtrlN新局直接在MainWindow的QAction里设setShortcut。别小看这几个键测试的时候反复点菜单很烦有了快捷键自己调试都快很多。QAction的triggered信号连到对应的槽槽里调m_board-undo()然后update()即可。我自己的习惯是每加一个功能就先在模型层写一个最小可验证的用例确认逻辑对了再接界面。六子棋这个项目我前后重构过三次前两次都是因为把胜负判定写在了BoardWidget里后来想加 AI 的时候发现逻辑和绘图缠在一起只能推倒重来。第三次把BoardModel彻底独立出去之后加 AI、加网络对战、加棋谱导出都只是往模型层加接口界面几乎不用动。如果你也打算把这个项目继续做下去建议一开始就把模型层摘干净后面会省下大量后悔药。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →