课程定位: 围棋打谱辅助系统系列课程的第一部分。在 Part 0 完成摄像头采集和棋盘矫正之后,本章构建完整的围棋规则引擎和虚拟棋盘 UI,实现一个可交互的完整对弈程序。
前置知识: Part 0(棋盘检测与矫正)、C++17、Qt6、数据结构和算法基础
预计阅读: 65 分钟 | 本章新增代码: ~2000 行
1. 为什么需要规则引擎?
Part 0 完成了"看”——摄像头能识别棋盘上有什么。但要实现真正的对弈,我们需要"大脑”:
flowchart LR
subgraph Part0["Part 0: 眼睛"]
CAM["摄像头"]
DETECT["棋盘检测"]
RECTIFY["透视矫正"]
end
subgraph Part1["Part 1: 大脑"]
RULES["规则引擎<br/>GoBoard"]
UI["虚拟棋盘<br/>GoBoardWidget"]
GAME["游戏控制<br/>GoGame"]
end
CAM --> DETECT --> RECTIFY
RULES --> UI
GAME --> RULES
GAME --> UI
规则引擎负责:
- 落子是否合法?(交叉点被占?自杀?劫?)
- 哪些棋子被提?(气为 0 的对方组)
- 对局是否结束?(双方 Pass)
- 谁赢了?(计目)
本章从零实现一个 ~200 行的规则引擎,配 ~350 行的棋盘渲染控件,再用 ~200 行的控制器把它们串成一个完整游戏。
2. GoBoard:棋盘数据结构
2.1 设计选择
| 方案 | 内存 | 优点 | 缺点 |
|---|---|---|---|
Stone[19][19] |
1444B | 直观 | 遍历需嵌套循环 |
Stone[361] 一维 |
361B | 缓存友好、简洁 | 需坐标转换 |
bitset<361> ×2 |
90B | 极省内存 | 无法表示 EMPTY |
选用
Stone[361]一维数组。
2.2 核心定义
class GoBoard {
public:
static constexpr int SIZE = 19;
static constexpr int TOTAL = 361;
static constexpr int DIRS[4][2] = {{-1,0},{1,0},{0,-1},{0,1}};
enum Stone { EMPTY = 0, BLACK = 1, WHITE = 2 };
Stone at(int row, int col) const { return m_grid[idx(row,col)]; }
static bool onBoard(int r, int c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
static int idx(int r, int c) { return r * SIZE + c; }
static int row(int i) { return i / SIZE; }
static int col(int i) { return i % SIZE; }
private:
Stone m_grid[TOTAL] = { EMPTY };
};
3. 气的计算:BFS 洪泛填充
从目标位置出发,BFS/DFS 遍历所有同色连通棋子。遇到的空交叉点 → 计入气的集合。
flowchart TD
START["startIdx (row,col)"] --> CHECK{"m_grid[idx] == color?"}
CHECK -->|"❌ EMPTY"| DONE["返回空 Group"]
CHECK -->|"✅"| PUSH["visited[idx]=true<br/>入栈"]
PUSH --> LOOP{"栈空?"}
LOOP -->|"否"| POP["弹出栈顶 → 检查 4 邻域"]
POP --> NEIGH{"邻格状态?"}
NEIGH -->|"EMPTY"| LIB["加入 liberties[]"]
NEIGH -->|"同色 & 未访"| NEWVISIT["visited=true, 入栈"]
NEIGH -->|"异色/出界"| SKIP["跳过"]
LIB --> LOOP
NEWVISIT --> LOOP
SKIP --> LOOP
LOOP -->|"是"| RETURN["返回 Group{stones[], liberties[]}"]
GoBoard::Group GoBoard::computeGroup(int startIdx) const
{
Group g;
Stone color = m_grid[startIdx];
if (color == EMPTY) return g;
bool visited[TOTAL] = { false };
bool libAdded[TOTAL] = { false };
std::vector<int> stack;
stack.push_back(startIdx);
visited[startIdx] = true;
while (!stack.empty()) {
int i = stack.back(); stack.pop_back();
g.stones.push_back(i);
int r = row(i), c = col(i);
for (auto &d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (!onBoard(nr, nc)) continue;
int ni = idx(nr, nc);
Stone s = m_grid[ni];
if (s == EMPTY) {
if (!libAdded[ni]) {
g.liberties.push_back(ni);
libAdded[ni] = true;
}
} else if (s == color && !visited[ni]) {
visited[ni] = true;
stack.push_back(ni);
}
}
}
return g;
}
4. 提子逻辑:先杀对方,再判自杀
落子后顺序:检查对方所有邻接组(气=0→移除)→ 检查自己所在组(气=0→回退)→ 检查劫(哈希重复→回退)。
std::vector<int> GoBoard::captureOpponent(int placedIdx, Stone myColor)
{
Stone oppColor = (myColor == BLACK) ? WHITE : BLACK;
std::vector<int> captured, checked;
int r = row(placedIdx), c = col(placedIdx);
for (auto &d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (!onBoard(nr, nc)) continue;
int ni = idx(nr, nc);
if (m_grid[ni] != oppColor) continue;
bool already = false;
for (int ci : checked) if (ci == ni) { already = true; break; }
if (already) continue;
Group g = computeGroup(ni);
for (int si : g.stones) checked.push_back(si);
if (g.liberties.empty())
for (int si : g.stones) {
m_grid[si] = EMPTY;
captured.push_back(si);
}
}
return captured;
}
5. 劫争:Zobrist 哈希 + 超级劫
给每个 (位置, 颜色) 预生成 64-bit 随机数,局面哈希 = XOR 所有放子位置。超级劫禁止回到任何曾出现过的局面。
static uint64_t ZOBRIST[TOTAL][3];
std::unordered_set<uint64_t> m_history;
// playMove() 中:
uint64_t newHash = computeHash();
if (m_history.count(newHash)) return {}; // 劫, 禁止!
m_history.insert(newHash);
6. 终局计目:洪泛分领地
终局时 BFS 找出所有空连通区域 → 仅邻接黑→黑领地,仅邻接白→白领地,邻接双方→公气不计。
Territory GoBoard::scoreTerritory() const
{
Territory t;
bool visited[TOTAL] = { false };
for (int i = 0; i < TOTAL; i++) {
if (m_grid[i] != EMPTY || visited[i]) continue;
std::vector<int> region;
bool touchesBlack = false, touchesWhite = false;
std::stack<int> stack; stack.push(i); visited[i] = true;
while (!stack.empty()) {
int idx = stack.top(); stack.pop();
region.push_back(idx);
int r = row(idx), c = col(idx);
for (auto &d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (!onBoard(nr, nc)) continue;
int ni = GoBoard::idx(nr, nc);
if (m_grid[ni] == EMPTY && !visited[ni]) {
visited[ni] = true; stack.push(ni);
} else if (m_grid[ni] == BLACK) touchesBlack = true;
else if (m_grid[ni] == WHITE) touchesWhite = true;
}
}
if (touchesBlack && !touchesWhite) t.black += (int)region.size();
else if (touchesWhite && !touchesBlack) t.white += (int)region.size();
}
return t;
}
7. GoBoardWidget:虚拟棋盘渲染
独立可复用的 QWidget 子类,绘制管线:木色背景 → 坐标标注 → 19×19 网格 → 9 星位 → 棋子 → 动画 → 标记 → 光标。
7.1 坐标转换
bool GoBoardWidget::widgetToBoard(const QPointF &wp, int &row, int &col) const
{
double fx = (wp.x() - m_origin.x()) / m_cellSize;
double fy = (wp.y() - m_origin.y()) / m_cellSize;
col = (int)std::round(fx);
row = (int)std::round(fy);
double dx = fx - col, dy = fy - row;
double dist = std::sqrt(dx*dx + dy*dy);
return (row >= 0 && row < 19 && col >= 0 && col < 19 && dist < 0.45);
}
8. 棋子 3D 效果与落子动画
QRadialGradient 模拟光照:黑子深色渐变 + 高光点,白子浅色渐变 + 细边框。落子时缓出曲线(t = 1 - (1-e)²)从 75%→100%,280ms。
void GoBoardWidget::drawStone(QPainter &p, int row, int col,
Stone s, double alpha, double scale)
{
QPointF center = boardToWidget(row, col);
double r = m_cellSize * 0.44 * scale;
p.save();
p.setOpacity(alpha);
p.setBrush(QColor(0, 0, 0, 60));
p.drawEllipse(center + QPointF(r*0.08, r*0.08), r, r);
if (s == Black) {
QRadialGradient grad(center - QPointF(r*0.3, r*0.3), r * 1.2);
grad.setColorAt(0.0, QColor(90, 90, 90));
grad.setColorAt(0.6, QColor(40, 40, 40));
grad.setColorAt(1.0, QColor(15, 15, 15));
p.setBrush(grad);
p.drawEllipse(center, r, r);
p.setBrush(QColor(255, 255, 255, 40));
p.drawEllipse(center - QPointF(r*0.25, r*0.25), r*0.25, r*0.25);
} else {
QRadialGradient grad(center - QPointF(r*0.3, r*0.3), r * 1.2);
grad.setColorAt(0.0, QColor(255, 255, 255));
grad.setColorAt(0.5, QColor(240, 240, 235));
grad.setColorAt(1.0, QColor(200, 195, 185));
p.setBrush(grad);
p.setPen(QPen(QColor(160, 150, 140), r * 0.04));
p.drawEllipse(center, r, r);
}
p.restore();
}
9. GoGame:游戏控制器
状态机:BlackTurn ⇄ WhiteTurn,双 Pass → GameOver。悔棋用完整局面快照压栈/弹栈。
stateDiagram-v2
[*] --> BlackTurn
BlackTurn --> WhiteTurn: play() 合法 / pass()
WhiteTurn --> BlackTurn: play() 合法 / pass()
BlackTurn --> GameOver: 第2次连续 pass
WhiteTurn --> GameOver: 第2次连续 pass
class GoGame : public QObject {
Q_OBJECT
public:
QVector<int> play(int row, int col);
void pass();
bool undo();
QString finalScore() const;
GoBoard::Stone currentPlayer() const { return m_turn; }
bool isGameOver() const { return m_gameOver; }
int capturedBlack() const;
int capturedWhite() const;
signals:
void boardChanged();
void turnChanged();
void gameEnded();
void movePlayed(int row, int col, GoBoard::Stone color);
private:
GoBoard m_board;
GoBoard::Stone m_turn = GoBoard::BLACK;
int m_consecutivePasses = 0;
bool m_gameOver = false;
int m_capturedBlack = 0, m_capturedWhite = 0;
struct MoveRecord { /* 完整局面备份 */ };
std::vector<MoveRecord> m_undoStack;
};
10. 完整对弈应用
flowchart LR
GW["GoBoardWidget<br/>棋盘渲染+交互"] -->|clicked| GAME["GoGame<br/>游戏控制器"]
BTN["按钮栏<br/>Pass/Undo/Score"] -->|action| GAME
GAME -->|play| BOARD["GoBoard<br/>规则引擎"]
BOARD -->|captured| GAME
GAME -->|boardChanged| GW
GAME -->|turnChanged| STATUS["状态栏<br/>回合/提子/手数"]
UI 层: GoBoardWidget ←→ GoGame → GoBoard (逻辑层)
↑ 按钮栏/状态栏 ↑
信号连接:clicked → onBoardClick → game.play() → refreshBoard / refreshUI → onGameEnded
11. 读秒计时:Byo-yomi 倒计时
11.1 日本式读秒规则
初始: 双方各 30 分钟主时间
↓
主时间耗尽 → 进入读秒: 3 次 × 30 秒
↓
落子在时限内 → 秒数重置回 30, 次数不变 ✅
读秒用尽 → 次数 3→2→1→0 → ⏰ 超时负
11.2 GoTimer 类设计
class GoTimer : public QObject {
Q_OBJECT
public:
void setTime(int mainMin, int byoPeriods, int byoSec);
void start();
void stop(); // 暂停 + 读秒重置 (落子后)
void pause(); // 暂停但保留秒数
void resume();
QString timeDisplay() const; // "29:45" 或 "3×25"
bool isInByoyomi() const;
bool isTimeUp() const;
signals:
void timeUp();
void tick();
void byoyomiStarted();
void byoyomiPeriodConsumed();
void lowTimeWarning(); // ≤10s主 / ≤5s读秒
};
tick 回调:
void GoTimer::onTick() {
if (m_inByoyomi) {
m_byoRemainingS--;
if (m_byoRemainingS <= 0) {
m_byoRemainingP--;
if (m_byoRemainingP <= 0) { m_timeUp = true; emit timeUp(); return; }
m_byoRemainingS = m_byoSecPer;
emit byoyomiPeriodConsumed();
}
} else {
m_mainRemaining--;
if (m_mainRemaining <= 0) {
if (m_byoPeriods > 0) { m_inByoyomi = true; emit byoyomiStarted(); }
else { m_timeUp = true; emit timeUp(); }
}
}
emit tick();
}
11.3 与 GoGame 集成
// 落子时切换计时
if (m_turn == BLACK) { m_blackTimer.stop(); m_whiteTimer.start(); }
else { m_whiteTimer.stop(); m_blackTimer.start(); }
// 超时 → 对方胜
connect(&m_blackTimer, &GoTimer::timeUp, [this]() {
m_gameOver = true; emit gameEnded();
});
低时间触发 lowTimeWarning() → 面板变红底闪烁(≤10s 主时间 / ≤5s 读秒)。
12. 计时配置 + 暂停恢复
12.1 计时设置对话框
点击 ⚙ 按钮弹出:主时间(1-180分) / 读秒次数(0-10) / 每次读秒(5-120秒)。应用即新局。
12.2 暂停/恢复
暂停: 双方计时冻结 (pause() — 保留读秒当前秒数), 棋盘禁用
恢复: 当前玩家继续计时 (resume()), 棋盘重新可用
12.3 倒计时总开关
底部 ☑ ⏱ 倒计时 复选框可一键关闭所有计时,方便自由对弈。
13. 音效系统
基于 QApplication::beep() + QTimer 队列实现非阻塞音效:
| 事件 | 音效 | 触发位置 |
|---|---|---|
| 落子 | 1 beep | GoGame::play() 成功 |
| 提子 | 2 beep | GoGame::play() 捕获 > 0 |
| 虚手 | 1 beep | GoGame::pass() |
| 读秒警告 | 1 beep | GoTimer::lowTimeWarning |
| 超时 | 3 beep | GoTimer::timeUp |
| 终局 | 2 beep | 双 Pass 后 |
双方独立开关:[🔊 ⚫] [🔊 ⚪] — 读秒警告与超时始终播放。
14. 构建与运行
add_executable(go-game
go_game.cpp
src/GoBoard.cpp src/GoBoardWidget.cpp
src/GoGame.cpp src/GoTimer.cpp src/GoSound.cpp
)
target_link_libraries(go-game PRIVATE Qt6::Widgets)
对弈流程:黑方点击落子 → 白方点击落子 → 提子动画 → Pass/悔棋 → 计时切换 → 双方 Pass 终局计分。
15. 小结
Part 1 完成的功能
✅ GoBoard 规则引擎 — BFS 气计算 / 提子 / 劫检测 / 终局计目
✅ GoBoardWidget — 木色棋盘 / 3D 渐变棋子 / 淡入动画 / 光标高亮
✅ GoGame 控制器 — 回合交替 / Pass / 悔棋 / 提子计数 / 中日计分
✅ 完整对弈应用 — 点击落子 / 规则校验 / 消息反馈 / 终局弹窗
✅ GoTimer 读秒计时 — 主时间 + 日本式 byo-yomi / 秒数重置 / 超时判负
✅ 计时配置 + 暂停 — 参数对话框 / 一键暂停恢复 / 倒计时总开关
✅ 音效系统 — 落子/提子/读秒/超时 beep / 双方独立开关
代码量一览
| 模块 | 文件 | 行数 |
|---|---|---|
| 规则引擎 | GoBoard.h/cpp |
~350 行 |
| 棋盘控件 | GoBoardWidget.h/cpp |
~350 行 |
| 游戏控制 | GoGame.h/cpp |
~300 行 |
| 计时器 | GoTimer.h/cpp |
~200 行 |
| 音效 | GoSound.h/cpp |
~80 行 |
| 对弈应用 | go_game.cpp |
~380 行 |
| 总计 | ~1700 行 |
下一部分预告
Part 2: 屏幕光标 + AI 对弈引擎
- 棋盘→屏幕坐标映射
- GTP 协议集成 (KataGo)
- 摄像头落子检测 → AI 自动回应
- 完整人机对弈流程
附录: V3 架构重构记录
截止 2026-05-11,项目已完成从单层 src/*.cpp 到分层架构的 v3 重构。
目录重组
src/core/ ← GoBoard, GoGame, GoTimer, GoSound 规则引擎+控制器
src/command/ ← ICommand, CommandInvoker 命令模式调度
src/engine/ ← IGoEngine, MockEngine AI 接口+测试桩
src/detect/ ← GoBoardDetector, BoardRectifier, ... 视觉检测
src/ui/ ← GoBoardWidget, MainWindow 表现层
src/net/ ← MjpegStream 网络层
GoGame 新增接口(向后兼容)
CommandInvoker* invoker() // 命令调度器
bool executeCommand(ICommand* cmd) // 命令入口
void setEngine(IGoEngine* engine) // 注入 AI
IGoEngine* engine() // AI 查询
bool isAiMode() // 模式查询
// 原有接口保持不变
QVector<int> play(int row, int col);
void pass();
bool undo();
构建系统
go-core.lib ← GoBoard + GoGame + GoTimer + GoSound + CommandInvoker
go-engine.lib ← IGoEngine + MockEngine
go-game.exe ← go-core + go-engine + Qt6::Widgets
go-board-calib.exe ← go-core + OpenCV + Qt6::Widgets + Qt6::Network
项目排期
| Phase | 内容 | 状态 |
|---|---|---|
| P0 | 基础视觉 + 架构重构 | ✅ Done |
| P1 | 棋子识别 + 局面感知 | 🎯 当前 |
| P2 | 屏幕光标显示 | ⬜ |
| P3 | AI 对弈 + SGF 打谱 | ⬜ |
| P4 | 产品化打磨 | ⬜ |