ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++五子棋项目实战:从数据结构到AI算法完整实现

C++五子棋项目实战:从数据结构到AI算法完整实现

简介:一款由 C++ 编写的五子棋程序压缩包,同时支持人机对战与人人对战,适合初学游戏编程的开发者学习类设计、AI 走棋逻辑与界面交互。压缩包共 7 个文件,包含 3 个 exe 可执行程序、3 个 cpp 源码和 1 个 txt 说明文档,整体仅 106KB,体量轻巧,便于快速下载与对照阅读;其中 exe 可直接运行体验,cpp 源码则便于逐行分析。源码提供了鼠标版、键盘版以及人对人版等多种实现,分别对应不同的输入处理与界面渲染方式,人机版中可重点研究基于规则或搜索剪枝的落子决策思路。目前已有 180 人学习下载,尤其适合希望从完整案例入手、提升 C++ 项目组织能力和游戏编程实战经验的读者。通过阅读这份源码,可以梳理棋盘表示、落子验证、胜负判断和双方交互等核心模块,为后续开发更复杂的策略游戏打下扎实基础。

1. 这个C++五子棋项目:适合谁,能练到什么

如果你正处在C++入门后“能看懂语法、但没写过一个完整程序”的阶段,用五子棋源码做一次整工程落地,是很合适的选择。它不依赖复杂框架,核心逻辑几百行就能写完,却天然覆盖了数组/vector操作、坐标与边界处理、状态机、简单AI算法这些面试和课设高频内容。更重要的是,它同时支持人机和人人两种对战模式,意味着你可以先走通“玩家对玩家”的交互与胜负判定,再在这个基础上替换出一个能和你下棋的AI,难度是递进的,不会一上来就被劝退。这篇笔记不带具体的开源包讲解,而是讲清楚一套我习惯采用的C++五子棋源码结构、每条关键逻辑和参数怎么设,以及我自己在这类项目里踩过的坑。

2. 先定架构:五子棋源码里的数据结构和模块划分

2.1 棋盘用一维数组还是二维数组?我为什么选vector

很多零基础源码喜欢用int board[15][15]这种固定二维数组,简单直接。但在我自己写的版本里,第一版就是用它,后来加“复盘存档”功能时才发现硬伤:棋盘大小写死在代码里,想改成19路围棋盘得动全工程。所以做源码时我一般把棋盘封装成一个类,内部用vector<vector<int>> board,行数和列数通过构造参数传入。这样不仅棋盘路数可变,也方便以后接入MFC或Qt时直接迁数据。

#include <vector> class GomokuBoard { public: explicit GomokuBoard(int size = 15) : m_size(size) { // 初始化二维vector,0表示空,1表示黑子,2表示白子 m_board.assign(m_size, std::vector<int>(m_size, 0)); } bool in_bounds(int x, int y) const { return x >= 0 && x < m_size && y >= 0 && y < m_size; } bool place(int x, int y, int player) { if (!in_bounds(x, y) || m_board[x][y] != 0) { return false; // 越界或重复落子 } m_board[x][y] = player; return true; } int get(int x, int y) const { return m_board[x][y]; } int size() const { return m_size; } private: int m_size; std::vector<std::vector<int>> m_board; };

这一段是源码的地基层。assign在这里的作用是把棋盘清零,而不是用resize后残留旧值。注意place函数把越界和重复落子一起拦住了,后面在UI层输入坐标时就不用再重复写边界判断。参数player我用1和2来区分黑白,而不是0和1,这样board[x][y] == 0直接代表空格,判断“谁赢了”时不用做偏移。

2.2 落子合法性:越界与重复落子双检查

这是人人对战里最常见的翻车点:用户输入坐标时可能给“15 15”,棋盘只有0到14,越界;也可能在已有子的位置再落一颗,覆盖之前的棋子。上面代码把两个检查都放进place里了,但如果你在源码里看到的是“UI层检查一遍、逻辑层再检查一遍”,那不是冗余,而是故意分两层。我见过一个简化版源码只在UI层做了判断,鼠标点击事件里没拦,结果逻辑层照样覆盖,复盘时棋谱错乱。双检查的代价就是两次比较,对五子棋这种低频操作完全无所谓。

// 从输入流读坐标的示例,兼容"3,4"和"3 4"两种格式 bool read_coordinate(int& x, int& y, int limit) { std::string line; if (!std::getline(std::cin, line)) return false; std::replace(line.begin(), line.end(), ',', ' '); // 把逗号统一替换成空格 std::istringstream iss(line); if (!(iss >> x >> y)) return false; return x >= 0 && x < limit && y >= 0 && y < limit; }

注意std::replace那一步,很多控制台版源码会忽略用户输入“5,7”这种情况,只认空格分隔。逗号替换成空格后用istringstream解析,避免写两套读取逻辑。参数limit直接传棋盘尺寸,坐标合法性的“0到14”边界就统一在函数内处理,调用方不用再分散写if (x < 0 || x >= 15)。

2.3 游戏主循环:状态机让“人人/人机”切换只改一行

一个完整的五子棋程序循环里至少有三个状态:对战中、分出胜负、棋盘下满和棋。我的做法是用一个GameStatus枚举驱动主循环,而不是用布尔标志isRunning加一堆break。因为后期加“重新开始”“悔棋”功能时,状态机只需要新增枚举值和对应case,不会把主循环改成乱麻。

enum class GameStatus { Playing, Win, Draw }; int current_player = 1; GameStatus status = GameStatus::Playing; GomokuBoard board(15); while (status == GameStatus::Playing) { // 打印棋盘(代码略,见后续小节) print_board(board); int x, y; std::cout << "玩家" << current_player << ",请输入落子坐标(x y): "; if (!read_coordinate(x, y, board.size())) { std::cout << "输入不合法,请重新输入。\n"; continue; } if (!board.place(x, y, current_player)) { std::cout << "该位置已有棋子,换个位置。\n"; continue; } if (check_win(board, x, y, current_player)) { status = GameStatus::Win; break; } if (is_board_full(board)) { status = GameStatus::Draw; break; } // 关键切换点:人人模式直接换手,人机模式则调用AI current_player = (current_player == 1) ? 2 : 1; if (current_player == 2 && play_mode == PlayMode::HumanVsAI) { ai_move(board, current_player); // 让AI走一步,内部落子 if (check_win(board, last_x, last_y, current_player)) { status = GameStatus::Win; } else if (is_board_full(board)) { status = GameStatus::Draw; } } }

这段循环代表“人人”和“人机”的核心区别。在人人模式下,play_mode不满足AI调用条件,所以第二个玩家也是人工输入;在人机模式下,玩家永远是1号(黑子),轮到2号时调用ai_move。这块参数值得注意:AI落子后同样要检查胜负和满盘,不能只检查人类玩家的落子,否则会出现AI已经五连了、程序还提示玩家继续输入的黑匣子现象。

3. 胜负判定源码:四个方向数连子,少绕弯路

3.1 五个子是否连成一线:从落点向两端扩展

胜负判定是五子棋源码成败的分水岭。网上不少版本用“遍历整个棋盘,对每个点看横竖斜四个方向”的写法,逻辑简单但白费很多循环。我一般用落子点为中心的判定:从最后一颗棋子的坐标出发,朝某个方向的正向数一遍,再朝反向数一遍,加起来判断是否大于等于5。比如水平方向,就是从(x, y)往右数到尽头、再往左数到尽头。四个基本方向是水平、垂直、主对角线、副对角线。

bool check_win(const GomokuBoard& board, int x, int y, int player) { // 四个方向的位移向量:水平,垂直,主对角线,副对角线 const int dx[4] = {1, 0, 1, 1}; const int dy[4] = {0, 1, 1, -1}; for (int dir = 0; dir < 4; ++dir) { int count = 1; // 当前落子本身算一个 // 正向数:从落点沿方向前进 for (int step = 1; ; ++step) { int nx = x + dx[dir] * step; int ny = y + dy[dir] * step; if (!board.in_bounds(nx, ny) || board.get(nx, ny) != player) { break; } ++count; } // 反向数:从落点沿反方向后退 for (int step = 1; ; ++step) { int nx = x - dx[dir] * step; int ny = y - dy[dir] * step; if (!board.in_bounds(nx, ny) || board.get(nx, ny) != player) { break; } ++count; } if (count >= 5) { return true; } } return false; }

这里的边界判断是重点。C++数组越界不报错但会读到脏数据,典型的坑就是:棋子在边界附近连成五子,却因为坐标减出负数而读到了上一行的内存,误判成“没赢”。所以in_bounds必须放在get之前。参数dx, dy数组把四个方向的搜索纳入了同一个循环,后面代码里不必复制四遍逻辑。

3.2 长连不算五子棋违规,但源码里要区分

五子棋有一些规则变体,比如无禁手规则下“六连”也算赢;有禁手规则下黑棋“长连”判负。如果你只是做一个课设或自己玩,我建议源码里不做禁手判断,count >= 5直接判赢就够了。但代码里写成>= 5而不是== 5是有讲究的:当一行里出现连续六个同色棋子时,== 5会在刚好数到五个时提前停止,虽然也赢了,但会掩盖掉“继续数还有第六颗”的信息,不利于以后扩展禁手规则。

3.3 下满棋盘判定:不要每次遍历全盘,维护一个落子计数

is_board_full如果直接双重循环扫全盘,15×15的棋盘无所谓,但如果以后把棋盘扩展到19路或更大,每次都O(n²)就不划算了。我在源码里用一个move_count成员变量,每成功落子一次就加1,满盘条件就是move_count == size * size。这样不仅快,还方便实现“第几步下在哪”的棋谱记录。

class GomokuBoard { public: bool place(int x, int y, int player) { if (!in_bounds(x, y) || m_board[x][y] != 0) { return false; } m_board[x][y] = player; ++m_move_count; return true; } bool is_full() const { return m_move_count == m_size * m_size; } private: int m_move_count = 0; };

这个m_move_count还顺带解决了一个调试痛点:打印当前步数。尤其是人机对战模式下,你发现AI某一手特别蠢时,能直接定位到是第几步触发的,不必靠肉眼数棋盘上的黑子白子。

4. 人机AI设计:让源码里的AI不那么蠢

4.1 从随机落子到找“当前最优点”的评分函数

人机对战里最基础的做法是让AI每次选择一个空位落子,问题是选哪个。完全随机会让AI看起来像人工智障;把所有空位都试一遍然后选一个能赢的位置,又太昂贵。我采用的思路是“评分函数”:给棋盘上每个空位打一个分,这个分的依据就是如果AI落在这里,能在四个方向上形成多大的潜力;如果对手落在这里,会在四个方向上形成多大的威胁。然后AI选择“自己得分 + 威胁对手得分”最高的位置。

int evaluate_point(const GomokuBoard& board, int x, int y, int ai_player) { int human_player = (ai_player == 1) ? 2 : 1; int score = 0; // 四个方向,每个方向看两件事:AI在这下能形成什么,对手在这下能形成什么 const int dx[4] = {1, 0, 1, 1}; const int dy[4] = {0, 1, 1, -1}; for (int dir = 0; dir < 4; ++dir) { int ai_count = 1; // 假设AI在这里落子 int human_count = 1; // 假设对手在这里落子 // 遍历一侧的几个位置,统计连续同色棋子的数量 for (int step = 1; step <= 4; ++step) { int nx = x + dx[dir] * step; int ny = y + dy[dir] * step; if (!board.in_bounds(nx, ny)) break; int cell = board.get(nx, ny); if (cell == ai_player) ++ai_count; else break; // 遇到空格或对手棋子就停 } for (int step = 1; step <= 4; ++step) { int nx = x - dx[dir] * step; int ny = y - dy[dir] * step; if (!board.in_bounds(nx, ny)) break; int cell = board.get(nx, ny); if (cell == ai_player) ++ai_count; else break; } // 对AI落子的成五潜力打分 if (ai_count >= 5) score += 100000; else if (ai_count == 4) score += 10000; else if (ai_count == 3) score += 1000; else if (ai_count == 2) score += 100; // 再对“对手如果落在这里”的威胁打分 int cell2 = board.get(x, y); // 当前是空位,不影响 for (int step = 1; step <= 4; ++step) { int nx = x + dx[dir] * step; int ny = y + dy[dir] * step; if (!board.in_bounds(nx, ny)) break; int cell = board.get(nx, ny); if (cell == human_player) ++human_count; else break; } for (int step = 1; step <= 4; ++step) { int nx = x - dx[dir] * step; int ny = y - dy[dir] * step; if (!board.in_bounds(nx, ny)) break; int cell = board.get(nx, ny); if (cell == human_player) ++human_count; else break; } if (human_count >= 5) score += 80000; // 堵对手成五,权重略低 else if (human_count == 4) score += 8000; else if (human_count == 3) score += 800; else if (human_count == 2) score += 80; } return score; }

这段评分函数有几个参数值得细说。步数上限step <= 4是因为五子棋只需要看连续五个,超过5的距离对本手落子没有影响;如果你要支持长远规划,可以把它加大,但会显著增加计算量。完全堵住对手的权重我调成了AI成五的0.8倍,因为实战中“先手优势”通常让进攻的分值略高于防守,但“必须堵对手的四连”往往比“自己去做三连”更紧急。看到这段代码里对human_player的统计是独立进行的,没有复用ai_count的结果,因为一个空位不可能同时放两颗子,必须分别假设AI落子、对手落子两种情形。

4.2 选择落子点:只搜空位,别搜全盘,加速明显

拿到评分函数后,AI的主循环就是遍历所有空格,算分,找最大分值位置。但全盘15×15有225个位置,每次AI落子都要评估225个位置,每个位置还要做四个方向各几步的统计,控制台版勉强能跑,但如果你做了MFC界面,鼠标点击后卡顿一下就会非常明显。我常用的优化是只评估“有邻居”的空位:一个空位如果周围两格内没有任何棋子,那它当前既不形成进攻也不形成防守,直接跳过。这样AI每一步的评估量能从225降到几十。

bool has_neighbor(const GomokuBoard& board, int x, int y, int radius = 2) { for (int i = -radius; i <= radius; ++i) { for (int j = -radius; j <= radius; ++j) { if (i == 0 && j == 0) continue; int nx = x + i; int ny = y + j; if (board.in_bounds(nx, ny) && board.get(nx, ny) != 0) { return true; } } } return false; } // AI移动 void ai_move(GomokuBoard& board, int ai_player) { int best_x = -1, best_y = -1; int best_score = -1; for (int x = 0; x < board.size(); ++x) { for (int y = 0; y < board.size(); ++y) { if (board.get(x, y) != 0) continue; if (!has_neighbor(board, x, y)) continue; int score = evaluate_point(board, x, y, ai_player); if (score > best_score) { best_score = score; best_x = x; best_y = y; } } } board.place(best_x, best_y, ai_player); }

这里radius = 2是经验参数。太大会让AI评估大量无关空位,太小会让AI忽略“隔一个空格就能成五”的位置。实战里我先用1试,发现AI经常在开局前几手乱走,因为周围没子可联系;改成2后,AI会更早参与布局,算力开销还在可接受范围。如果你想把AI做得更强,可以在evaluate_point里增加“活三”“冲四”“眠三”这些特定棋型的识别,而不是只数连续同色棋子的个数。那是另一套逻辑,但基于这个评分结构去扩展是最顺的路径。

4.3 引擎级别不够深:两层搜索已经能拦住大部分辣手

前面评分函数是“只考虑当前这一步”的贪心,AI能看到对方下一步吗?看不见。所以要让它更强,就得在ai_move里加一层对对手应手的评估。常见做法是“两步搜索”:AI尝试在某个空位落子,然后模拟对手所有可能的回应,看哪个落子点能让AI在对手回应后的局面里评分最高。搜索深度越多,AI越强,但耗时指数级上升。我的经验是五子棋15×15棋盘上,三层搜索加邻居剪枝,控制台版每步大约半秒到一秒,可接受;再深只能靠Alpha-Beta剪枝,否则就是给用户送“思考人生”的体验。

5. 避坑复盘:编译、坐标、手数、MFC窗口的5个高频问题

5.1 坐标“15 15”到底存在哪?用户视角和数组下标永远差1

现象:玩家输入“1 1”落子,棋盘上却显示第一行第一列没反应,或者输入“15 15”直接崩溃。

原因:很多人习惯从1开始数,但C++数组下标从0开始。如果你在UI提示里给玩家的是“请输1~15”,落子函数接收后直接用board[x][y]就越界了。

解决:在read_coordinate读入后统一减1。更省事的做法是提示语直接写“请输入0到14”,让玩家适配程序,而不是程序适配玩家。如果做MFC图形版,鼠标点击的坐标是像素值,要除以格子像素宽度再加偏移,这个转换也属于同类问题,提前定好公式能少调半天界面。

5.2 胜负判定总在斜线方向漏判,一查是数组方向写错了

现象:横着竖着都能正常判赢,但正对角线方向(从左上到右下)五连总是判不出来。

原因:副对角线的方向向量dy应该是 -1,但我第一版写成了dy[3] = 1,结果副对角线方向和主对角线方向重合了,永远检查不到真正斜向的连子。

解决:方向数组写完先打印日志验证。比如在棋盘上人为构造一组斜线五子,调用check_win后打印count值,看有没有走对方向。我后来习惯把四个方向的向量写成同一组测试用例,跑过一遍再往下写AI,不然AI也会基于错误的方向评估,下出来的棋四不像。

5.3 AI首手落子位置诡异:评分函数没处理“空空如也”的棋盘

现象:AI执黑先走第一步时,所有空位周围都没有邻居,has_neighbor返回 false,循环一圈下来best_x还是 -1,best_y也是 -1,place(-1, -1)直接越界。

原因:我加了邻居剪枝优化后,忘了处理“棋盘上一个子都没有”的边界情况。

解决:在ai_move开头加一条判断,如果棋盘没有棋子,直接落在中心点(7, 7)或(size/2, size/2)。这不是偷懒,五子棋先手落中心是最常规的布局选择,也避免首手去搜索全部225个点。

5.4 UTF-8 源码在旧版 Visual C++ 里编译乱码

现象:源码在 2015 以上版本的 Visual Studio 打开正常,一拿到老环境或换成 MinGW 编译,控制台输出中文全是乱码。

原因:源代码文件的编码与编译器运行时环境不一致,常见于文件保存为 UTF-8 无 BOM,而旧版MSVC默认按 GBK 解析字符串字面量。顺带一提,热词里那个microsoft visual c++ 2015-2022 redistributable (x64)是你把程序发给没装运行库的电脑时必要的依赖,但乱码通常是编码问题不是运行库问题。

解决:两个思路。一是把源码保存为 UTF-8 with BOM,MSVC 能正确识别;二是把所有中文提示换成英文或拼音,省去编码烦恼。如果你用 VSCode 写C++,在设置里把files.encoding改成gbk或确保保存时带 BOM 也能解决。

5.5 人机模式下AI自己下完不判胜负,要玩家再走一步才弹胜利

现象:AI落完第五颗连子,程序没有立刻提示“AI获胜”,玩家还得输入一手坐标,然后才突然结束。

原因:主循环里只检查了玩家落子后的胜负,没有检查AI落子后的局面。人机模式切换玩家的代码路径里漏掉了check_win调用。

解决:把check_win和满盘检查提取成一个公共函数check_game_over(board, last_x, last_y, player),无论人走还是AI走,都在落子后调用同一套逻辑。这样不会因为后续加“AI vs AI”模式而再次漏检。

6. 进阶一步:把控制台源码改成MFC窗口版的人机对战

如果你已经跑通了控制台版,想把它变成带界面的程序,最常见的迁移方式是保留GomokuBoard、check_win、ai_move三个核心模块,只把主循环换成消息驱动。MFC里在OnLButtonUp里拿到点击坐标,按下标公式换算成棋盘坐标,调用place,再调ai_move,然后Invalidate()触发重绘。这里有一个我自己试过的做法:把AI计算放到一个线程里,否则AI思考时窗口会卡死,鼠标拖拽都没反应。简单实现是每次玩家落子后,用PostMessage发一个自定义消息,在消息响应函数里执行AI,执行完再重绘,这样至少不会在AI计算期间冻结界面。

// MFC消息响应示例:鼠标抬起时转成棋盘坐标并处理 void CGomokuDlg::OnLButtonUp(UINT nFlags, CPoint point) { // 棋盘起点(30,30),每格30像素,点击坐标减去起点再整除格宽 int x = (point.x - 30) / 30; int y = (point.y - 30) / 30; if (!m_board.in_bounds(x, y)) return; if (m_board.place(x, y, 1) && check_win(m_board, x, y, 1)) { MessageBox(_T("你赢了!")); return; } // 派发自定义消息触发AI走棋,而不是直接调用 PostMessage(WM_USER_AI_MOVE); }

这个参数30是棋盘格子的像素宽,如果你用了不同的棋盘起点或格子大小,这里必须和OnPaint里画棋盘的位置严格一致。MFC版真正麻烦的不是逻辑,而是坐标换算和刷新控制,这部分调试我花了不少时间。另外一点血泪经验:不要试图在OnLButtonUp里直接写while循环等待AI计算完再重绘,Windows消息机制不吃这套,必须把AI算法放到工作线程里去做。

对五子棋这一套核心结构,如果以后想继续加料,我一般会建议从“悔棋”和“棋谱导出”做起:悔棋用栈存储每一步坐标和玩家索引,出栈回退时把棋盘对应位置清空、m_move_count--;棋谱导出则把move_count, player, x, y按行写入CSV。这两个功能都是在现有数据结构上加薄薄一层,不破坏AI和胜负判断的逻辑,特别适合用来练手。实际写完你会发现,最值钱的部分不是AI,而是那个棋盘类设计得够不够稳,希望这篇笔记的思路能帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进