
简介一款由 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路围棋盘得动全工程。所以做源码时我一般把棋盘封装成一个类内部用vectorvectorint board行数和列数通过构造参数传入。这样不仅棋盘路数可变也方便以后接入MFC或Qt时直接迁数据。#include vector class GomokuBoard { public: explicit GomokuBoard(int size 15) : m_size(size) { // 初始化二维vector0表示空1表示黑子2表示白子 m_board.assign(m_size, std::vectorint(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::vectorstd::vectorint 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还是 -1best_y也是 -1place(-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 BOMMSVC 能正确识别二是把所有中文提示换成英文或拼音省去编码烦恼。如果你用 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而是那个棋盘类设计得够不够稳希望这篇笔记的思路能帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。