最近把 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 是工程利器,但对一个三字棋项目来说编译环境配置成本偏高。图形教学库通常就几个核心 API,initgraph初始化窗口、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,从 x=MARGIN 到 x=MARGIN+BOARD_W line(MARGIN, MARGIN + i * CELL_W, MARGIN + BOARD_W, MARGIN + i * CELL_W); // 竖线:x = MARGIN + i * CELL_W,从 y=MARGIN 到 y=MARGIN+BOARD_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,这个顺序能让你的开发过程顺畅太多。