☰
Qt六子棋项目实战:从棋盘建模到悔棋重做的完整开发指南
2026/10/18 3:43:18 网站建设 项目流程

简介:这是一套面向计算机相关专业学生的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::vector<Move>& history() const { return m_history; } private: std::array<std::array<int, kSize>, kSize> m_board; std::vector<Move> 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 = true,AI 落子完成后置回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.qrc

boardmodel只依赖<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 左右刚好能看清又不抢眼。这个功能对新手很友好,能减少“点错位置”的挫败感。

第三个细节是键盘快捷键。Ctrl+Z悔棋、Ctrl+Y重做、Ctrl+N新局,直接在MainWindow的QAction里设setShortcut。别小看这几个键,测试的时候反复点菜单很烦,有了快捷键,自己调试都快很多。QAction的triggered信号连到对应的槽,槽里调m_board->undo()然后update()即可。

我自己的习惯是,每加一个功能就先在模型层写一个最小可验证的用例,确认逻辑对了再接界面。六子棋这个项目我前后重构过三次,前两次都是因为把胜负判定写在了BoardWidget里,后来想加 AI 的时候发现逻辑和绘图缠在一起,只能推倒重来。第三次把BoardModel彻底独立出去之后,加 AI、加网络对战、加棋谱导出都只是往模型层加接口,界面几乎不用动。如果你也打算把这个项目继续做下去,建议一开始就把模型层摘干净,后面会省下大量后悔药。希望帮到你。

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

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

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

立即咨询