C++井字棋AI实战:图形界面绘制与堵棋逻辑全解析

📅 发布时间:2026/10/21 3:40:06
C++井字棋AI实战:图形界面绘制与堵棋逻辑全解析
最近把 C 游戏编程里很经典的一个入门项目重做了一遍——用图形界面配合 AI 堵棋实现一个简易三字棋小游戏。这个项目我从头梳理了选题、界面绘制、AI 决策、状态流转一整条链路。三字棋也就是井字棋规则虽然简单到小孩都会玩但真要在 C 里把棋盘画面渲染出来、让鼠标能落子、再让电脑学会主动堵你里面涉及的坐标换算、事件循环、胜负判定、搜索逻辑一样都跑不掉。这篇文章就把整个实现过程拆开讲透代码结构和思路直接可复用适合正在学 C 游戏编程、想拿一个小项目练手的同学。1. 项目拆解与技术选型先想清楚三字棋到底要做什么1.1 棋盘数据结构怎么定很多初学者上来就纠结“界面怎么写”我的建议是先抛开界面把棋盘模型想明白因为所有逻辑都围绕这个数据结构展开。三字棋是 3×3 九宫格最直观的做法是用一个二维数组const int SIZE 3; int board[SIZE][SIZE] {0}; // 0表示空1表示玩家2表示AI为什么用 int 而不用 char因为后面做 AI 模拟落子时要频繁地“试放一颗子再撤掉”int 类型在赋值、比较、传递上都更省心也不会有 char 和 int 混用时那堆隐式转换的麻烦。这里我甚至不建议用枚举三字棋就三种状态0/1/2 数字足够清晰除非你后面打算扩展成五子棋或黑白棋才值得提前引入常量定义。数据结构的第二个细节是坐标方向。棋盘数组的第几行对应屏幕的哪一条线必须在一开始就定死。我的习惯是board[row][col]row 表示从上到下的行号col 表示从左到右的列号后面画图和 AI 计算全部统一用 row、col 表达绝不混用 x、y。很多 bug 都出在“画图用 x 横 y 竖逻辑用 row 行 col 列”这种双重标准上。1.2 图形层的取舍思路“图像界面”这四个字是标题里的重点也是很多 C 新手的第一道坎。C 本身没有官方 GUI可选方案大致是原生 Win32 API、Qt、跨平台多媒体库、以及一些专为教学设计的轻量级图形库。我的建议很直接如果目标是尽快把游戏逻辑跑通就用轻量级教学图形库。Win32 写窗口模板、消息循环、控件注册光框架代码就大几十行容易劝退Qt 是工程利器但对一个三字棋项目来说编译环境配置成本偏高。图形教学库通常就几个核心 APIinitgraph初始化窗口、line和circle画线画圆、getMouseMsg取鼠标消息半小时就能上手非常适合把注意力集中在“游戏逻辑”而不是“窗口系统”上。你觉得这些代码不像工程没关系我们要的本来就是这个项目最核心的两件事界面交互和 AI 堵棋。图形库只承担画板功能这是合理的取舍。如果你选的是别的图形库也没问题下面的绘制思路和逻辑是跨库通用的改函数名就能迁移。1.3 项目文件划分项目虽小我也不建议把所有代码堆进一个 main.cpp。不是说单文件不行而是当你开始调试 AI 逻辑时会发现绘制函数和决策函数混在一起每改一个判断条件都要在几百行里翻来翻去。我的划分方式是把代码按职责拆成几个部分game.h // 公共常量、棋盘数组、函数声明 main.cpp // 主循环、状态切换 draw.cpp // 界面绘制、鼠标点击换算 logic.cpp // 落子、胜负判定、平局判定 ai.cpp // AI决策这是典型的“显示、控制、逻辑”三层拆分思路draw 层只看棋盘状态然后画出来logic 层只改状态ai 层只读状态并返回要落子的坐标。各层之间通过全局的board数组沟通。虽然每个文件都不长但这种结构让你后期加功能、改 bug 的位置非常明确比单文件舒服太多。2. 图形界面落地画棋盘、响应鼠标、处理刷新2.1 网格线和棋子的绘制逻辑图形界面的第一步是把棋盘画出来。三字棋棋盘就是 3×3 的网格四条线而已。但为什么好多人的画面总不对劲问题通常出在格子尺寸的计算上。先定义一个窗口大小假设是 480×480 像素。为了让棋盘周围留出边距不要直接让网格顶到窗口边缘。计算方式很简单const int MARGIN 60; // 四周留白 const int BOARD_W 360; // 可用棋盘宽度 const int CELL_W BOARD_W / SIZE; // 每格120像素 for (int i 0; i SIZE; i) { // 横线y MARGIN i * CELL_W从 xMARGIN 到 xMARGINBOARD_W line(MARGIN, MARGIN i * CELL_W, MARGIN BOARD_W, MARGIN i * CELL_W); // 竖线x MARGIN i * CELL_W从 yMARGIN 到 yMARGINBOARD_W line(MARGIN i * CELL_W, MARGIN, MARGIN i * CELL_W, MARGIN BOARD_W); }棋子更简单遍历整个board数组如果某个格子是玩家棋子就在对应格子中心画一个实心圆如果是 AI 棋子画一个空心圆或不同颜色的圆。格子中心的坐标是int centerX MARGIN c * CELL_W CELL_W / 2; int centerY MARGIN r * CELL_W CELL_W / 2;注意绘制颜色一定要区分玩家和 AI否则一盘棋看下来眼睛都花了。我用蓝色实心圆表示玩家红色空心圆表示 AI简单也直观。2.2 鼠标点击与落子的坐标换算这一步是整个图形界面里最容易被忽略、但最值得细讲的点。鼠标点一下系统给你的是像素坐标(mouseX, mouseY)而棋盘数组要的是(row, col)。换算公式其实很容易int col (mouseX - MARGIN) / CELL_W; int row (mouseY - MARGIN) / CELL_W;这行代码背后的道理是先去掉边距得到鼠标在棋盘区域的相对坐标再除以格子宽度就能得到第几列第几行。但我第一次做的时候忘记检查边界结果鼠标点在棋盘最外侧留白区域时col 和 row 根本不在 0 到 2 的范围内数组越界程序直接崩掉。加上防御式判断是必须的if (row 0 || row SIZE || col 0 || col SIZE) return;另一个细节是应该用“鼠标按下”还是“鼠标弹起”来触发落子。我的经验是用弹起事件更符合操作直觉因为用户会下意识地把鼠标移到一个格子再松开如果用按下事件经常出现用户刚按下还没对准格子就已经落子的情况。弹起事件能明显减少误触。2.3 双缓冲刷新与防闪烁棋盘每次变化后都要重绘最蠢的写法是“擦掉整个窗口、重新画所有东西”。如果窗口是纯色的还好一旦棋子多了或者背景复杂了重绘就会出现明显的闪烁尤其在某些图形库中这块问题非常突出。解决办法是双缓冲先在内存中把整幅画面画好再一次性地贴到屏幕上。许多教学图形库提供了BeginBatchDraw和EndBatchDraw这样的成对接口中间所有的绘制操作都发生在后台缓冲区最后统一刷新。使用方式极简单BeginBatchDraw(); // 所有绘制代码背景、网格线、棋子 EndBatchDraw(); FlushBatchDraw();我第一次没有用双缓冲落子时画面闪得厉害尤其是下到第五六颗子的时候整个棋盘像在“跳动”。作用了双缓冲立刻顺滑这也是图形程序一个非常基础且关键的知识点。建议把这个习惯带到所有需要频繁重绘的小项目里。3. AI堵棋模块的设计从“乱下”到“会堵”再到“不输”3.1 棋型扫描如何判断“差一子成线”AI 的核心能力是判断“某条线上是否已经有两颗己方棋子再下一颗就能赢”。这一步是所有棋类 AI 的地基实现方式很直白——遍历所有可能的行、列、两条对角线数一数这条线上有多少个目标玩家的棋子。三字棋的胜利线一共有 8 条3 条横线、3 条竖线、2 条对角线。我先写一个通用的胜利判定函数它能判断当前棋盘上某个玩家是否已经构成三连bool checkWin(int b[SIZE][SIZE], int player) { // 行和列 for (int i 0; i SIZE; i) { if (b[i][0] player b[i][1] player b[i][2] player) return true; if (b[0][i] player b[1][i] player b[2][i] player) return true; } // 两条对角线 if (b[0][0] player b[1][1] player b[2][2] player) return true; if (b[0][2] player b[1][1] player b[2][0] player) return true; return false; }这个函数看似平平无奇但它把“胜利”抽象成了一个标准判断后面无论是玩家落子后检查是否赢、AI 模拟落子后检查是否赢还是判断某个棋子是否构成三连都能复用。代码量少但价值很大。3.2 进攻与防守的优先级先赢棋再堵人标题强调“AI堵棋”但完整可玩的 AI 不能只会堵人。如果 AI 只会防守那每一盘棋最多和棋完全不会进攻游戏性很差。所以决策模块的优先级我定为三个梯度先看自己有没有能赢的位置再堵对手即将赢的位置最后才是普通占位。这里的核心技巧是“模拟落子”。AI 把所有空格都试一遍假设把自己的一颗棋子放上去然后调用checkWin如果赢了说明这个位置就是必赢点// 第一优先级AI自己的必赢点 for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (board[r][c] 0) { board[r][c] AI; if (checkWin(board, AI)) { // 在这里落子 } board[r][c] 0; // 记得还原棋盘 } } }同理第二优先级是堵棋。把每个空格暂时放成玩家的棋子如果这样玩家会赢说明这个位置是玩家的必赢点AI 就必须在这里落子堵住// 第二优先级堵住玩家的必赢点 for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (board[r][c] 0) { board[r][c] PLAYER; if (checkWin(board, PLAYER)) { // 在这里堵住 } board[r][c] 0; } } }很多人看完会说这不就是穷举吗对就是穷举。三字棋只有 9 个格子AI 决策时最多试 9 个空格每次试完做一次 8 条线的胜利判断性能开销完全可以忽略。这种“先模拟再评估”的做法正是后面扩展到极大极小值搜索、神经网络评估的最基础雏形。先用这种基础方式跑通等理解了思路再谈更复杂的策略。3.3 兜底策略中心、角、边的选择逻辑如果 AI 既没有必赢点也不用堵人它应该下在哪这就轮到启发式位置打分登场。井字棋有个经典经验中心位置价值最高因为它同时处于两条对角线和中间行、中间列的交汇处控制中心能同时牵制多条线路其次是四个角最后才是四条边。我把这块策略简化成一个优先级数组写起来非常直观const int POS_PRIORITY[SIZE][SIZE] { {3, 2, 3}, {2, 4, 2}, {3, 2, 3} };中心给 4 分四个角给 3 分边给 2 分。AI 在没有进攻和防守任务时扫描所有空格选分数最高的那个位置落子。你可能会问这个分数是怎么定的实话说一部分来自井字棋的策略常识一部分来自实测。当你把这个打分表在真机跑上几十把后会发现它能让 AI 在对局中表现出很强的控制力至少不会出现明显“呆滞”地乱下。3.4 AI决策的完整流程和测试方法把三部分拼起来AI 决策就是一个顺序函数void aiMove(int aiRow, int aiCol) { // 1. 遍历所有空格模拟AI落子若形成三连则选它 // 2. 遍历所有空格模拟玩家落子若玩家形成三连则堵它 // 3. 遍历所有空格按POS_PRIORITY选分数最高的位置 }这里必须强调一个顺序问题为什么进攻优先于防守因为如果 AI 已经有必赢点了还去堵人就能出现“放着自己的胜利不下反而送对方拖延”的滑稽局面。先攻后守是棋类 AI 的一条铁律。测试 AI 也不需要完整地跑一盘棋而是构造一些特定局面来验证比如让棋盘上 AI 有两颗棋子在一条线上第三个位置为空跑aiMove看它是不是果断落子再构造玩家差一步成线的局面看 AI 是不是跑过去堵。这种“局面化测试”比从头到尾随机下棋验证高效得多。4. 主循环与状态管理让游戏真正“转起来”4.1 游戏状态流转画好了棋盘写好了 AI接下来要把它们拼成一个完整游戏。这需要主循环和状态管理。很多新手容易把逻辑一股脑堆在循环里结果下完棋不知道怎么重开或者游戏结束后点棋盘还能继续落子。我引入了一个简单状态机用枚举表示游戏所处阶段enum GameState { STATE_PLAYING, STATE_OVER }; GameState state STATE_PLAYING;在主循环的每一帧程序只处理当前状态下该做的事PLAYING 状态下接收鼠标、判断落子是否符合规则、触发 AI 下棋OVER 状态下不再接收任何落子只显示终局提示。当玩家按 R 键或点击“再来一局”区域时重置棋盘状态回到 PLAYING。这个状态机虽然只有两个状态但它把“可交互”和“不可交互”的边界划得清清楚楚为以后加入菜单、暂停等功能打下了基础。4.2 回合切换与落子校验回合切换听起来简单玩家下一步检查胜负没结束AI 下一步再检查胜负。但这里有一个容易出错的地方AI 决策之后必须再次检查棋盘是否已满或者是否已经分出胜负因为 AI 的落子也可能直接终结游戏。流程伪代码如下// 玩家回合 if (playerValidMove(row, col)) { placeStone(row, col, PLAYER); if (checkWin(board, PLAYER)) { state STATE_OVER; showMsg(玩家胜利); } else if (isBoardFull(board)) { state STATE_OVER; showMsg(平局); } else { // 进入AI回合 aiMove(aiRow, aiCol); placeStone(aiRow, aiCol, AI); if (checkWin(board, AI)) { state STATE_OVER; showMsg(AI胜利); } else if (isBoardFull(board)) { state STATE_OVER; showMsg(平局); } } }playerValidMove负责两件事点击坐标是否落在棋盘内以及该格子是否为空。第二次判断尤其重要否则你在已经被占的格子上执行placeStone会直接把对手的棋子覆盖掉棋盘上出现“两颗棋重叠”的诡异情况。这个校验逻辑是回合切换的守门员。4.3 胜负与平局的判定时机判定时机比判定函数本身更值得琢磨。我在写第一版的时候把胜负判定分散在鼠标事件、绘制函数、AI 函数里结果一盘棋结束的状态乱七八糟明明已经三连了还能继续落子棋盘满了却不提示平局。原因是每个子模块都在自己方便的地方做了判定但没有一个统一的“判定入口”。正确的做法是把胜负检查固定在“每次落子之后”这个节点统一执行。无论是玩家落子还是 AI 落子落子之后紧跟判定一旦发现终局立即改状态并停止后续落子流程。平局判定则要在胜负判定之后执行因为棋盘满且无胜者才算平局。这里有个小陷阱如果棋局最后一子既使得棋盘满又形成了三连胜负优先不能判成平局。5. 就这实际上踩坑记录那些让程序“看起来不对”的细节5.1 斜线判定写错坐标方向不一致我第一时间写完的 AI 老是出现奇怪行为明明玩家已经在一个斜对角上放了两颗子AI 却不堵反而去堵一条根本不存在的横线。排查过程完全是我在这类小项目里最典型的经历从 AI 逻辑开始逐行查折腾了半天才发现问题不在 AI而在胜负判定函数。原因是我在写第二条对角线时犯了方向错误。我用board[0][2]、board[1][1]、board[2][0]这条线但在绘制和坐标换算时习惯把 col 当作从左到右。这本身没错问题出在数组的第二个下标我要确认它到底是行还是列。一旦某一段代码把row和col对调了斜线就变成了“左下到右上”和“左上到右下”混在一起判定自然全乱。这个坑的教训就是必须把所有代码里关于 row、col 的注释统一写清楚尤其是在 AI 模拟落子、胜负判定、绘制函数三个地方来回调用的项目里。建议在每个函数开头注释// row: 从上到下, col: 从左到右关键时刻能救命。5.2 AI只堵不攻所有对局都变成平局我给 AI 写了非常完整的堵棋模块放弃了进攻。结果就是AI 永远不会输但也永远赢不了玩家每盘都能把棋盘下满最后无一例外是平局。这种 AI 看起来“防御出色”实际上极其无聊完全破坏了游戏的可玩性。补上第三小节里说的“必赢点优先”后AI 的行为立刻变得有攻击性。不过这里我又踩了一个顺序坑一开始我把“进攻检查”和“防守检查”写反了AI 总是先堵玩家哪怕自己已经有两个棋连成线都不去赢。后来交换两个循环的顺序AI 才表现出正常的棋感。我的体会是AI 决策模块一定要按“赢棋 堵棋 占位”的顺序来并且要有测试用例固定住这个行为不然以后改动时会不小心破坏优先级。5.3 重绘频率太高导致画面闪烁前面提到双缓冲这项做法的必要性不是在文档里看出来的而是真的被闪得烦了才意识到。最初我的主循环是“收到鼠标消息 - 修改棋盘 - 立刻重绘整个窗口”没有批量缓冲支持时画面每一帧都有明显的“刷屏”感。有图形库自带的批量绘制接口自然是首选。万一用的库不支持批量绘制还有一个保守方案只在棋盘状态变化时重绘而不是每一帧都重绘。这个方案配合状态机的思路也能大幅减少闪烁因为它把“无意义的重绘”全部过滤掉了。这块经验放之四海而皆准——减少不必要的渲染永远是图形程序最有效的性能优化手段之一。5.4 代码从零散到结构化为什么不能都写在 main 里最初我的版本全部写在 main.cpp大约三百行逻辑上分成几个大段落。写的时候觉得还挺顺但一旦想改 AI 的启发式打分就得在鼠标事件、绘制函数、决策函数之间反复跳跃。有一天加的“增加重新开始按钮”功能因为修改了绘制函数结果意外破坏了点击区域盘了半小时才发现是某个全局变量的值在绘制时被顺手改了。后面我把 AI、绘制、判定拆到独立文件后这类问题少了很多。拆分的核心收益不是代码变短而是“函数的访问边界”变清晰了。绘制函数只能读棋盘不能改棋盘AI 函数只被主循环调用不会意外在鼠标事件里触发。这种分解习惯是 C 游戏编程里非常值得养成的工程素养。6. 扩展方向三字棋骨架还能长成什么样6.1 从堵棋到完整 MinMax 搜索当前 AI 的策略是“只看一步”的贪心搜索它不关心两步之后会发生什么。三字棋毕竟只有 9 个格子完全可以设计一个完整的 MinMax 搜索让 AI 枚举所有可能的分支评估每个位置的最坏情况从而做到“绝对不输”。甚至连 α-β 剪枝都能用上三字棋的分支数不多不需要复杂的剪枝也能在毫秒级返回结果。这个扩展适合作为下一阶段的学习目标先理解当前 AI 为什么只能赢在眼前然后实现一个递归搜索树最后对比两种 AI 在实战中的差异。你会发现MinMax 版 AI 几乎不会犯错而贪心版 AI 偶尔会让出关键位置。6.2 界面层的升级目前所有画面都是用基本几何图形画出来的。升级方向很明确用贴图替代圆形比如 X 和 O 的素材增加落子动画让棋子从落点位置弹性弹入给胜利连线加一个高亮描边效果。这些升级点本质都是同一件事——增加游戏反馈的即时性和沉浸感。三字棋虽然简单但反馈做得好玩起来一样有质感。6.3 从三字棋到五子棋AI模块的复用如果把数组从 3×3 改成 15×15胜利条件从“三连”改成“五连”你就得到了五子棋的骨架。绘制、交互、状态管理大部分代码都能迁移真正需要重写的是两处胜利判定从 8 条线变成所有方向上的连续扫描AI 的启发式策略要从中心打分升级成“活四、冲四、活三”等更复杂的棋型检测。到时候回头看你会发现三字棋这个项目的价值恰恰是帮你把棋类游戏的通用骨架打扎实了。做完这个项目的最大收获不是学会了几条line和circle的调用而是理解了一个游戏程序“界面、逻辑、决策”三部分之间如何分工协作。写 AI 堵棋时开始的“模拟落子再评估”思路后来写任何策略型小游戏都派得上用场。如果你也在用 C 写自己的第一个小游戏我建议你先别急着堆功能把棋盘模型和状态流理清楚再往上盖界面和 AI这个顺序能让你的开发过程顺畅太多。