课程定位: 围棋打谱辅助系统系列课程的第一部分。在 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 产品化打磨