news 2026/11/2 5:34:15

C语言EasyX五子棋实战:图形界面、五连判定与AI评分

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言EasyX五子棋实战:图形界面、五连判定与AI评分

简介:「C语言五子棋EasyX」是一套面向C语言初学者与图形编程入门者的完整项目源码,围绕五子棋的人机对战、双人对弈逻辑展开,帮助读者在实战中理解C语言语法、内存管理与EasyX轻量级图形库的窗口绘制、落子与交互响应。压缩包共145个文件、约57.36MB,以jpg界面截图、mp3与wav音频素材、cpp源文件与h头文件为核心,另含obj、pdb、exe等编译生成物及vcxproj、sln工程配置,可直接用Visual Studio打开编译运行。项目涉及棋局状态管理、胜负判定以及Minimax、Alpha-Beta剪枝等AI搜索思路,并保留网络对战相关的通信设计,便于延伸学习socket编程与多线程。已有1689人学习下载,适合作为课程设计、期末大作业或图形界面开发的自学范例,对照源码可快速理清模块划分与调试流程。

1. 一套 640×640 窗口里的完整五子棋:先看它能跑到什么程度

下载包里躺着一个 640×640 的 exe,双击之后 15 路棋盘铺开,点一下落一颗带高光的黑子,AI 在零点几秒内回一手,赢棋时弹出一行「黑棋胜」。这就是「星空系列 C语言 五子棋 easyx」给人的第一印象,也是很多 C 语言学习者从控制台黑框迈向图形界面的第一道门。

它在 C 语言学习路径上的位置很明确:指针、二维数组、结构体都学完之后,总得有个能看见结果的东西。这套五子棋把图形窗口、鼠标交互、落子判定、简单 AI 塞进一个闭环里,代码量不大,改起来有反馈。适合想练手的学生,也适合拿到别人代码想加悔棋、加难度分级、换成人人对战的开发者。不适合指望它去打比赛的人,它的搜索是教学级,和正经棋力引擎差着量级。

2. EasyX 装不上、窗口一闪就没:环境与工程骨架的三处必改设置

拿到一份图形化 C 代码,八成的人卡在第一步:编译能过,运行没窗口;或者窗口出来了,一秒就消失。这不是代码写错了,是环境没搭对。这一章先把运行环境、字符集、工程分层这三件事理顺,后面所有关于棋盘和 AI 的讨论才有落脚点。

2.1 编译器版本和 EasyX 版本必须对得上

EasyX 本质上是给 Windows GDI 套了一层 C++ 风格的绘图接口,graphics.h不是 C 标准头,只对 MSVC 生态友好。用 gcc、Dev-C++、Code::Blocks 折腾它,属于给自己上难度。常见做法是 Visual Studio 2019 或 2022,配官方对应的 EasyX 安装包,装完在VS 安装目录\VC\Auxiliary\VS\include里能直接找到graphics.h。

装没装对,别急着跑五子棋,先写个最小验证程序:

#include <graphics.h> #include <conio.h> int main(void) { initgraph(640, 640); /* 创建 640×640 的绘图窗口 */ setbkcolor(WHITE); /* 设置背景色为白色 */ cleardevice(); /* 用背景色把整个窗口刷一遍 */ setlinecolor(BLACK); setlinestyle(PS_SOLID, 2); /* 实线,线宽 2 像素 */ circle(320, 320, 100); /* 在窗口正中画一个半径 100 的圆 */ _getch(); /* 卡住等待一次按键,否则程序瞬间结束 */ closegraph(); /* 关闭窗口,释放绘图资源 */ return 0; }

initgraph(w, h)的第三参数是窗口样式标志,默认 0 表示同时保留控制台窗口,方便printf调试;想让画面干净可以传EX_SHOWCONSOLE的反面用法,但调试期建议留着控制台。closegraph()必须和initgraph()成对出现,否则资源泄漏,反复调试几次之后可能连窗口都创建不出来。

提示:initgraph在远程桌面、部分虚拟机、显卡驱动异常的环境下可能返回失败。加一句if (GetHWnd() == NULL) { /* 打印错误并退出 */ }能省掉半小时的「为什么没反应」。

如果圆画出来了,说明图形库链路通了,问题只可能在五子棋代码本身;如果编译报「无法打开源文件 graphics.h」,那就是版本没匹配或者安装路径不对,重装一次比手动拷头文件靠谱。

2.2 字符集设置:中文乱码和 outtextxy 编译报错的共同根源

EasyX 在较新版本里把文本输出拆成了两套:多字节和 Unicode。项目属性 → 配置属性 → 高级 → 字符集,选「使用多字节字符集」,你就可以直接写outtextxy(10, 10, "黑棋胜");选「使用 Unicode 字符集」(VS 默认),同样的代码会报「无法从 const char* 转换为 LPCTSTR」,必须写成outtextxy(10, 10, _T("黑棋胜"))或L"黑棋胜"。

两种都能用,但必须统一。血泪经验是:从网上下载的单文件工程,作者可能是在多字节字符集下写的,你拿过来默认 Unicode,一编译几十个错,看着像代码坏了,其实只是设置差一个下拉框。我一般拿到陌生工程第一件事就是翻项目属性,确认字符集,再决定要不要给每个字符串加_T()。

中文显示还需要字体设置,否则可能出来一堆方块:

settextstyle(20, 0, _T("宋体")); /* 高度 20,宽度自适应,字体宋体 */ settextcolor(BLACK); outtextxy(20, 20, _T("黑棋胜"));

settextstyle(height, width, face)中 width 传 0 表示按比例自适应,比自己算宽度省事。字体名必须写系统里真实存在的,写个不存在的字体不会报错,只会默默回退成默认字体。

2.3 源文件分层:单文件能跑,但改不动

下载包里常见的是单个main.c包打天下,几百上千行全堆在一起。编译是方便,可一旦要改 AI 或者加悔棋,就得在几千行里翻。合格的拆法是按职责切成四层,头文件负责声明,源文件负责实现:

文件职责主要函数
main.c入口、消息循环、状态机main、game_loop
board.c / board.h棋盘数据、落子、悔棋、胜负board_place、board_undo、board_check_win
render.c / render.h所有绘图,不碰游戏逻辑render_board、render_stone
ai.c / ai.h评分与搜索ai_choose

头文件的写法决定了后面改起来顺不顺:

/* board.h */ #ifndef BOARD_H #define BOARD_H #define BOARD_SIZE 15 /* 15 路棋盘,14 个格子 */ #define EMPTY 0 #define BLACK 1 #define WHITE 2 extern int board[BOARD_SIZE][BOARD_SIZE]; void board_reset(void); int board_place(int r, int c, int player); /* 落子成功返回 1 */ int board_undo(int *r, int *c, int *player); /* 悔棋成功返回 1 */ int board_check_win(int r, int c, int player); /* 以 (r,c) 为中心判五连 */ int board_is_full(void); /* 棋盘下满返回 1 */ #endif

主循环用 EasyX 2021 之后推荐的ExMessage接口:

#include <graphics.h> #include "board.h" #include "render.h" int main(void) { initgraph(640, 640); BeginBatchDraw(); /* 开启批量绘图,消除闪烁,后面详说 */ board_reset(); render_all(); FlushBatchDraw(); /* 把内存里的画面一次性贴到屏幕 */ ExMessage msg; while (1) { /* peekmessage 非阻塞:有消息就取,没有就往下走,不会卡住动画 */ while (peekmessage(&msg, EM_MOUSE)) { if (msg.message == WM_LBUTTONDOWN) { handle_click(msg.x, msg.y); } } update(); /* 推进 AI 思考、动画等逻辑 */ } EndBatchDraw(); closegraph(); return 0; }

老教程里常见的MOUSEMSG m = GetMouseMsg();是阻塞式取消息,取不到就卡在那,动画和计时全停。新版 EasyX 的peekmessage(&msg, EM_MOUSE)只取鼠标类消息,配合while循环能把消息队列排空,避免快速点击时消息堆积。如果你手上的工程用的是老 API,PeekMouseMsg()是它的非阻塞版本,改个函数名即可。

3. 15 路棋盘的坐标换算:从鼠标像素到行列号的四舍五入

渲染这层看着最简单,实际是最容易出「玄学 bug」的地方:点这儿棋子跑那儿、棋子画到线中间、窗口缩放后全乱。根因基本都在坐标换算上。这一章把像素坐标和逻辑坐标的关系钉死,后面调 AI 才不会怀疑人生。

3.1 逻辑坐标与像素坐标互转

先把三个常量定死,全工程只认这一套:

#define WIN_SIZE 640 /* 窗口边长 */ #define MARGIN 40 /* 棋盘外边距,单位像素 */ #define CELL 40 /* 相邻两条线的间距 */ /* 由行列号求像素坐标:交叉点落在 (MARGIN + c*CELL, MARGIN + r*CELL) */ static inline int grid_to_px(int idx) { return MARGIN + idx * CELL; }

15 路棋盘有 15 条线、14 个间隔,所以棋盘区宽度是14 * 40 = 560,左右各留 40 边距正好 640,不用再凑。窗口尺寸改成别的值时,MARGIN和CELL必须跟着调,否则右边和下边会被裁掉一半。

反算要小心,整数除法是向零取整,不是四舍五入:

/* 像素坐标转行列号,越界返回 -1 */ static int px_to_index(int px, int py_is_row) { int v; /* 先挡掉棋盘外的点,防止负数参与除法后取整方向出错 */ if (px < MARGIN - CELL / 2) return -1; if (px > MARGIN + (BOARD_SIZE - 1) * CELL + CELL / 2) return -1; /* +CELL/2 后再除,等价于四舍五入到最近的交叉点 */ v = (px - MARGIN + CELL / 2) / CELL; if (v < 0 || v >= BOARD_SIZE) return -1; return v; }

举个具体数:MARGIN = 40、CELL = 40时,鼠标 x 落在 55,直接截断算是(55-40)/40 = 0,落在第 0 列;但 55 其实更靠近 60,也就是第 1 列。差 5 个像素看着不起眼,玩家真点的时候会觉得「明明点这儿,怎么下隔壁去了」。加上CELL/2之后(55-40+20)/40 = 0,还是 0,因为 55 到 60 差 5,到 40 差 15,确实应该算第 0 列——这个例子说明四舍五入是对的,手算一遍就能验证。

更稳的做法是再加一道距离过滤,让玩家点到两条线中间时直接忽略,而不是强行吸附:

int col = px_to_index(mx, 0); int row = px_to_index(my, 1); if (col < 0 || row < 0) return; /* 落在棋盘外 */ int dx = mx - grid_to_px(col); int dy = my - grid_to_px(row); if (dx * dx + dy * dy > (CELL / 3) * (CELL / 3)) return; /* 离交叉点太远,丢弃 */

半径取CELL/3大约是 13 像素,手感刚好:点得准就落子,点偏了不会误触。参数可以按自己习惯调到CELL/2,但别超过半个格子,否则相邻交叉点的判定区会重叠。

3.2 棋盘、星位与坐标标注的绘制顺序

绘图最怕顺序乱。正确的层次是:底色 → 网格线 → 星位 → 坐标文字 → 棋子 → 最后一手标记。后画的盖住先画的,棋子必须最后画,否则会被网格线切成碎块。

void render_board(void) { int i, p; setbkcolor(RGB(222, 184, 135)); /* 木色底,比纯白耐看 */ cleardevice(); setlinecolor(RGB(60, 40, 20)); setlinestyle(PS_SOLID, 1); for (i = 0; i < BOARD_SIZE; ++i) { p = grid_to_px(i); /* 横线:从最左到最右 */ line(MARGIN, p, MARGIN + (BOARD_SIZE - 1) * CELL, p); /* 竖线:从最上到最下 */ line(p, MARGIN, p, MARGIN + (BOARD_SIZE - 1) * CELL); } /* 四个星位 + 天元,五子棋惯例位置 */ { static const int star[5][2] = {{3,3},{3,11},{11,3},{11,11},{7,7}}; setfillcolor(RGB(60, 40, 20)); for (i = 0; i < 5; ++i) solidcircle(grid_to_px(star[i][1]), grid_to_px(star[i][0]), 4); } }

solidcircle(x, y, r)画无边框实心圆,用在这里画星位刚好;fillcircle是有边框的实心圆,画棋子时用它才能描出棋子边缘。两个函数名字相近,混用的话星位会多一圈黑边,细看很丑。

坐标标注是可选项,加上对复盘有用:

settextstyle(14, 0, _T("Consolas")); settextcolor(RGB(60, 40, 20)); for (i = 0; i < BOARD_SIZE; ++i) { TCHAR buf[4]; _stprintf_s(buf, 4, _T("%c"), _T('A') + i); /* 顶部 A~O */ outtextxy(grid_to_px(i) - 5, MARGIN - 24, buf); }

_stprintf_s是 MSVC 的安全版本,参数依次是缓冲区、缓冲区长度、格式串。缓冲区开 4 个字符够放一个字母加结尾符,开小了会直接断言崩溃,这类崩溃在调试器里看不出来是哪一行,只能靠猜。

3.3 棋子绘制与双缓冲:闪烁和「残影」的根治办法

不开启批量绘图时,每调用一次line、circle都是直接往窗口的设备上下文上画。一帧要画三十条线加若干棋子,用户会看到「擦除→重画」的中间状态,表现就是落子瞬间整个棋盘闪一下。EasyX 的BeginBatchDraw()把这一帧的所有绘制都攒在内存位图里,FlushBatchDraw()才一次性贴到屏幕。

void render_stone(int r, int c, int player, int is_last) { int x = grid_to_px(c); int y = grid_to_px(r); int rad = CELL * 45 / 100; /* 半径取格距的 45%,留缝不粘连 */ if (player == BLACK) { setfillcolor(RGB(35, 35, 35)); setlinecolor(RGB(10, 10, 10)); } else { setfillcolor(RGB(245, 245, 245)); setlinecolor(RGB(150, 150, 150)); } fillcircle(x, y, rad); /* 有边框实心圆,勾出棋子轮廓 */ /* 左上角小高光,制造球面感 */ if (player == BLACK) setfillcolor(RGB(105, 105, 105)); else setfillcolor(RGB(255, 255, 255)); solidcircle(x - rad / 3, y - rad / 3, rad / 4); if (is_last) { /* 最后一手打红点,方便复盘 */ setfillcolor(RGB(220, 60, 60)); solidcircle(x, y, 3); } }

半径 45% 而不是 50%,是为了让相邻两颗棋子之间留一条细缝。取 50% 时两个圆正好相切,看起来像连成一片,尤其在密集区域容易看不出哪颗是哪颗。

双缓冲的正确用法有一条铁律:每帧重绘前必须先cleardevice()。因为批量绘图的机制是「在已有画面上叠加」,不清屏的话上一帧的棋子、文字全留在那儿,越积越多,最后整屏糊成一片。正确流程是:

void render_all(void) { cleardevice(); /* 先清空内存位图 */ render_board(); /* 再按层次重画 */ render_all_stones(); render_ui(); FlushBatchDraw(); /* 最后一次性上屏 */ }

这个「清屏 → 全量重绘」的代价在 640×640 下可以忽略,别为了省这点开销去做局部擦除,局部擦除的坐标算错一次就是一道抹不掉的黑印,排查起来比重画一百次还费时间。

4. 落子、悔棋与五连判定:把全盘扫描换成四个方向的增量检测

棋盘画出来了,接下来是规则层。这一层决定了游戏「讲不讲理」:能不能在已有棋子的位置落子、五连算不算赢、悔棋退不退得干净。很多下载包在这里偷懒,用全盘遍历判胜负,15×15 每次落子扫 225 个点,游戏是能跑,但接上 AI 之后搜索里要反复调用判定,开销立刻放大。

4.1 棋盘状态与落子栈:为什么用 char 存坐标

棋盘用最朴素的二维数组,取值 0/1/2 分别表示空、黑、白:

int board[BOARD_SIZE][BOARD_SIZE]; typedef struct { signed char r; /* 行号 0~14,char 够用 */ signed char c; /* 列号 0~14 */ signed char player; /* 1 或 2 */ } Move; static Move history[BOARD_SIZE * BOARD_SIZE]; /* 最多 225 手 */ static int hist_top = 0;

用signed char而不是int,单条记录从 12 字节压到 3 字节。225 手总共不到 700 字节,全在一个缓存行附近,悔棋和 AI 回退时的遍历几乎不产生缓存未命中。这个规模下省不省其实无所谓,但养成习惯没坏处,将来把棋盘扩到 19 路或者做搜索时,一点一点省下来的内存会变成搜索深度。

落子函数要把所有非法情况挡在外面:

int board_place(int r, int c, int player) { if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) return 0; if (board[r][c] != EMPTY) return 0; if (player != BLACK && player != WHITE) return 0; if (hist_top >= BOARD_SIZE * BOARD_SIZE) return 0; /* 防御性上限,防越界写 */ board[r][c] = player; history[hist_top].r = (signed char)r; history[hist_top].c = (signed char)c; history[hist_top].player = (signed char)player; hist_top++; return 1; }

最后那个hist_top上限判断看着多余,其实是后悔药:如果某处状态机写错,胜利之后还允许继续落子,没有这道判断就会写出数组边界,程序当场崩在莫名其妙的地方。加上它最多是多一手无效落子,至少不崩。

悔棋就是把栈顶弹掉:

int board_undo(int *r, int *c, int *player) { if (hist_top == 0) return 0; hist_top--; if (r) *r = history[hist_top].r; if (c) *c = history[hist_top].c; if (player) *player = history[hist_top].player; board[history[hist_top].r][history[hist_top].c] = EMPTY; return 1; }

参数用指针回传,是因为调用方往往需要知道「退的是哪一手」,好把界面上的最后一手红点也挪走。传 NULL 表示不关心,函数内部做了判空,调用时传board_undo(NULL, NULL, NULL)也能用。

4.2 五连判定:四个方向、双向计数,复杂度是常数

不用扫全盘。既然只有新落下的那一颗子可能形成新的五连,就从它出发,沿四个方向往两边数:

static const int DIR[4][2] = { {0, 1}, /* 横 */ {1, 0}, /* 竖 */ {1, 1}, /* 主对角线 ↘ */ {1, -1} /* 副对角线 ↙ */ }; static int count_line(int r, int c, int dr, int dc, int player) { int n = 0; int rr = r + dr; int cc = c + dc; while (rr >= 0 && rr < BOARD_SIZE && cc >= 0 && cc < BOARD_SIZE && board[rr][cc] == player) { ++n; rr += dr; cc += dc; } return n; } int board_check_win(int r, int c, int player) { int d; for (d = 0; d < 4; ++d) { /* 自身 1 颗 + 正向连子 + 反向连子 */ int n = 1 + count_line(r, c, DIR[d][0], DIR[d][1], player) + count_line(r, c, -DIR[d][0], -DIR[d][1], player); if (n >= 5) return 1; } return 0; }

关键在于反向计数直接对方向取负,{1,-1}取反变成{-1,1},四个边界条件依然成立。这一手省掉了八方向的表,也省掉了「正向返回后还要再写一遍」的重复代码。

复杂度是 O(4×4×2),最多数 32 次,跟棋盘大小无关。对比全盘扫描的 15×15×4 次判断,快了将近两个数量级——单局人玩的时候感觉不出来,AI 搜索里每个叶子节点都要判一次,这个差距就变成「思考 0.2 秒」和「思考 5 秒」的区别。

注意:count_line的循环条件必须四个都写全。少写cc >= 0,副对角线往左下数到边界外时会读到上一行的数据,表现为「莫名其妙判赢」或者随机崩溃,而且只在特定位置复现,属于典型的玄学 bug。

平局判断简单粗暴,看历史栈是否满了:

int board_is_full(void) { return hist_top >= BOARD_SIZE * BOARD_SIZE; }

用hist_top而不是遍历数组找空格,是因为落子和悔棋都会同步维护这个计数,它天然等于已落子数。前提是所有改动棋盘的地方都走board_place和board_undo,谁要是图省事直接board[r][c] = 1,这个计数立刻失真,平局判定就永远不触发。

4.3 状态机:为什么「能不能点」比「点得对不对」更重要

人机对战的节奏是:玩家落子 → AI 思考 → AI 落子 → 回到玩家。这中间如果玩家连点两下,或者 AI 思考期间玩家还能操作,就会出现一次落两子、AI 下在自己棋子上的乱象。用状态机挡住:

typedef enum { ST_HUMAN_TURN, /* 等玩家落子 */ ST_AI_THINKING, /* AI 计算中,忽略鼠标 */ ST_GAME_OVER /* 已分胜负,只响应重开 */ } GameState; static GameState g_state = ST_HUMAN_TURN;

点击处理的第一句话就是状态判断:

void handle_click(int mx, int my) { int col, row; if (g_state != ST_HUMAN_TURN) return; /* 状态锁,一行挡住所有并发问题 */ col = px_to_index(mx, 0); row = px_to_index(my, 1); if (col < 0 || row < 0) return; if (!board_place(row, col, BLACK)) return; render_all(); if (board_check_win(row, col, BLACK)) { g_state = ST_GAME_OVER; show_result(BLACK); return; } if (board_is_full()) { g_state = ST_GAME_OVER; show_result(0); /* 0 表示平局 */ return; } g_state = ST_AI_THINKING; /* 交棒给 AI,此时玩家点不动 */ }

状态锁这一行比任何防抖、计时器都可靠。有人喜欢用「记录上次点击时间,间隔小于 200ms 就忽略」来做防抖,那治的是手抖,治不了逻辑上的重复触发;状态机是从根上保证同一时刻只有一个角色能落子。

悔棋要配合状态机一起复位,否则会出现「悔完还是 AI 的回合」:

void handle_undo(void) { if (hist_top == 0) return; board_undo(NULL, NULL, NULL); /* 先退掉最后一手 */ /* 人机模式下,如果退完轮到 AI 落子,再退一手回到玩家 */ if (hist_top > 0 && history[hist_top - 1].player == BLACK) { board_undo(NULL, NULL, NULL); } g_state = ST_HUMAN_TURN; /* 无论如何都把回合还给玩家 */ render_all(); }

判断条件history[hist_top - 1].player == BLACK的意思是:退完之后,栈顶那一手是黑棋,说明接下来该白棋(AI)走,所以再退一手。这个逻辑在「玩家执黑先手」的固定设定下成立;如果做了黑白互换,这里要跟着改成判断当前玩家是谁。

5. 五个翻车现场:从「能编译」到「真能玩」的排查清单

代码逻辑看着都对,跑起来就是不对劲,这类问题最耗人。下面五条是我在不同机器、不同版本上反复遇到的,按「现象 → 原因 → 解决」摆开,对着看基本能覆盖大部分现场。

5.1 双击 exe 窗口一闪就没

现象:在 Visual Studio 里按 F5 一切正常,把 exe 拷到桌面双击,黑框闪一下没了,什么都不留。

原因:main返回后进程立即结束,窗口跟着销毁。调试器会在退出前暂停,双击不会。另一种情况是initgraph创建窗口失败返回了空句柄,后面的绘图调用全部无效,程序直接走到return 0。

解决:结尾补一个等待,_getch()或者getmessage(EM_KEY),等到用户按键再退出。同时在initgraph之后立刻判空:

initgraph(WIN_SIZE, WIN_SIZE); if (GetHWnd() == NULL) { printf("绘图窗口创建失败,检查显卡驱动或换一台机器\n"); return 1; }

GetHWnd()返回窗口句柄,比检查返回值直观。远程桌面和部分虚拟机环境下 GDI 加速受限,也可能创建失败,这不是代码问题。

5.2 落子偏移半格,点到线中间也落子

现象:鼠标明明点在交叉点上,棋子却画到旁边一格;或者点在两条线的正中间,也照样落子。

原因:坐标反算用了整数除法截断,(x - MARGIN) / CELL在 x 略小于交叉点时算成前一列;另外行列传参顺序弄反,把msg.x传给了行号,落点就会沿对角线镜像偏移。

解决:反算时加CELL/2做四舍五入,再补一道距离过滤(见 3.1 的代码)。行列顺序统一约定为「x 算列、y 算行」,函数名直接写成px_to_col、py_to_row,比传一个is_row布尔参数更难写错。

5.3 斜向五连漏判,或者赢了之后程序崩溃

现象:横向竖向连五个都能判赢,斜着连五个毫无反应;或者赢棋之后玩家再点一下,程序直接退出。

原因:方向表只写了三个方向,漏掉{1,-1};count_line的循环条件只写了上界没写下界,副对角线往左下数时读到数组外;赢棋后状态机没锁,继续落子导致hist_top超过 225,写穿数组。

解决:方向表老老实实写满四条;count_line的四个边界条件一个都不能省;board_place里加hist_top上限判断做兜底。这三处改完,斜向判定和崩溃会一起消失,它们是同一个疏忽的两个表现。

5.4 AI 一思考,标题栏就变「未响应」

现象:玩家落子后,窗口标题栏出现「(未响应)」,鼠标转圈,过几秒 AI 落子后才恢复。任务管理器里 CPU 占满一个核。

原因:搜索是主线程里的同步死循环,中间不处理 Windows 消息。系统对超过 5 秒不取消息的窗口会标记为无响应,用户看着像卡死。

解决:在搜索的节点计数里插消息泵,每几千个节点取一次消息:

static void pump_messages(void) { MSG msg; while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } }

在搜索函数的节点循环里每 4096 个节点调一次。注意不要在子线程里直接调 EasyX 的绘图函数,GDI 句柄跨线程使用会出各种诡异问题;要并行就把「算」放子线程、「画」留主线程,搜索结果通过一个结构体回传。

5.5 悔棋之后 AI 和界面不同步

现象:人机模式点悔棋,棋盘上少了一颗子,但红点标记还在原位,而且 AI 立刻又落一手,等于白悔。

原因:只弹了一次栈,退掉的是 AI 那一手,轮到玩家时栈顶还是黑棋,状态机又把它当成「该 AI 走」;另外重绘用的是局部擦除,只擦掉了棋子的圆,没擦红点。

解决:悔棋退到「栈顶是黑棋」为止(见 4.3 的handle_undo),并把g_state强制置回ST_HUMAN_TURN;重绘一律走render_all()全量刷,别做局部擦除。红点标记这类小元素最容易在局部刷新里被漏掉,全量重绘虽然多画几十个圆,但省下的是排查时间。

6. 从「能下」到「下得像人」:评分表加权与自对弈验证

AI 的强弱,八成不在搜索深度,在评分表。深度加到 6 层,评估函数是歪的,它照样会为了一个活二放弃堵对面的活三。我一般先定形状分,再谈深度:

形状含义分值
连五已经五个10000000
活四两头都能成五200000
冲四一头被堵的四20000
活三两头都能成活四15000
眠三一头被堵的三2000
活二两头空的二1000
眠二一头被堵的二100

冲四分给得比活三高,是因为冲四下一步就赢,必须优先处理;但活三给到 15000,是因为两个活三叠起来能形成双三杀,纯靠冲四挡不住。这套数值不是标准答案,是调参的起点。

调参别靠「感觉它变强了」,那不靠谱。写个自对弈循环,让 AI 自己跟自己下两百局,统计先手胜率:

int selfplay(int rounds) { int k, black_win = 0, r, c, player; for (k = 0; k < rounds; ++k) { board_reset(); player = BLACK; while (1) { if (!ai_choose(player, &r, &c)) break; /* 无点可下 */ board_place(r, c, player); if (board_check_win(r, c, player)) { if (player == BLACK) black_win++; <p> <a href="https://download.csdn.net/download/gouxiaodan/10008733" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 5:33:23

CVPR论文写作与科研压力管理实战指南

1. 从标题看科研工作者的真实状态"下次再写一篇CVPR现在先别烦我了"这个标题生动展现了科研工作者在deadline压力下的真实状态。CVPR作为计算机视觉领域顶会&#xff0c;每年投稿量巨大&#xff0c;论文写作和修改过程往往伴随着巨大的时间压力和焦虑情绪。这个标题背…

作者头像 李华
网站建设 2026/11/2 5:33:07

C++格式化输出三套体系:printf、cout与std::format深度对比

1. 项目概述&#xff1a;为什么“C 占位符”突然成了高频学习关键词&#xff1f;最近在几个技术社区和初学者交流群中&#xff0c;频繁看到“C 占位符”被单独拎出来讨论——不是作为某本教材里的一个冷门小节&#xff0c;而是作为独立的学习主题被反复提问&#xff1a;“C里到…

作者头像 李华
网站建设 2026/11/2 5:32:26

Android MQTT应用开发与Google Play上架实践

1. 项目背景与挑战在Android应用开发中&#xff0c;MQTT协议因其轻量级、低功耗和高效性&#xff0c;成为物联网(IoT)领域最常用的通信协议之一。然而&#xff0c;当开发者需要将基于MQTT的Android应用上架Google Play时&#xff0c;往往会遇到以下典型问题&#xff1a;版本兼容…

作者头像 李华
网站建设 2026/11/2 5:31:14

SetFit:少样本学习在NLP分类任务中的革命性应用

1. SetFit&#xff1a;少样本分类的革新者在自然语言处理领域&#xff0c;少样本学习一直是个令人头疼的挑战。传统方法需要大量标注数据才能达到理想效果&#xff0c;而SetFit的出现彻底改变了这个局面。这个基于sentence-transformers的框架&#xff0c;仅需32个标注样本就能…

作者头像 李华
网站建设 2026/11/2 5:31:09

Formality常值mismatch排查指南:从综合优化到setup引导

1. 当formality卡在"几个常值"上&#xff1a;先搞清楚它在报什么做形式验证的同行大概都遇到过这种场景&#xff1a;RTL改完&#xff0c;跑formality做等价性检查&#xff0c;结果一上来就报出一堆不匹配的点&#xff0c;而且这些点往往集中在几个看起来"人畜无…

作者头像 李华
网站建设 2026/11/2 5:31:00

制造业数字化转型:关键技术、挑战与实施策略

1. 制造业数字化转型的现状与挑战2025年的制造业正经历着从传统生产模式向智能化跃迁的关键阶段。根据最新行业调研数据显示&#xff0c;全球已有超过78%的制造企业启动了不同程度的数字化项目&#xff0c;但其中仅有23%的企业真正实现了预期效益。这种"高普及率、低成功率…

作者头像 李华