C/C++图形编程入门:用EasyX库做小游戏 很多人在学完C语言、C的语法基础之后都会遇到同一个尴尬printf能打印出漂亮的字符却做不出一个“有画面”的程序——没有窗口没有鼠标没有能动的方块更别提做游戏了。我当年也是卡在这条线上直到碰上了EasyX这个图形库才算真正把“学过的C/C”变成了“能用的C/C”。这篇东西就是围绕EasyX图形库基础来写的。它不是什么高深引擎的入门也不是复杂框架的剖析而是解决一个最现实的需求用你最熟悉的C/C语言以最靠近底层的方式打开一个窗口、画点东西、响应键盘鼠标、跑起来一个游戏循环。这篇文章适合谁刚学完C/C想接触图形编程的同学、学校课程里要求做课设但不知道从哪入手的兄弟、还有那些在控制台里玩腻了想换点新鲜感的自学者。我尽量把常见配置、核心函数用法、还有我踩过的坑都讲清楚保证你看完能自己敲出第一个小游戏。1. 项目概述EasyX是什么为什么是它1.1 核心需求解析先说清楚EasyX到底是个什么东西。它本质上是针对Windows平台的一个图形库把Windows原生APIWin32 GDI里那些复杂的窗口创建、设备上下文、绘图调用封装成一套更接近初学者习惯的C/C函数接口。也就是说它解决的不是“怎么用图形库”而是“怎么让只会C/C的人也能做图形程序”这件事。为什么初学者特别容易卡在图形这一步因为Windows窗口程序设计本身的门槛和游戏逻辑无关——你要先处理“窗口类注册”“消息循环”“设备上下文”这一堆和画图无关的样板代码才能开始画一个小圆点。这对抱着“我想做个游戏”的人来说完全是劝退的。EasyX的价值就在这它把这些样板代码全部藏起来了。你只需要包含头文件、调用initgraph、画图、监听输入剩下的交给库里头的默认实现。我以前带过一个社团的同学他用Dev-C写学生管理系统都很溜一打开Win32空项目整个人懵了连入口函数都找不到该写在哪。换成EasyX之后半小时就画出了会动的小球。所以我说EasyX不是最强大的但它是最适合从“会语法”过渡到“有作品”的那座桥。1.2 影响范围与典型应用场景EasyX能做的图形程序范围远超很多人的想象。它虽然简单但并不是玩具。在我见过和做过的项目里有几类特别典型教学演示类排序算法可视化、数据结构图表演示、数学模型绘制。这类不需要太高的刷新率重点是把抽象的过程可视化成图形EasyX的画线、填充、文字接口用起来非常顺手。2D小游戏类俄罗斯方块、贪吃蛇、打砖块、飞机大战这些经典练手项目是EasyX用户量最大的一类。它支持贴图、透明处理、双缓冲绘图2D小游戏的常规需求基本都覆盖了。交互式工具类简易画板、波形显示、模拟面板。EasyX的消息函数能挂接鼠标和键盘做点小工具足够用。课程设计类每年期末都会有一大批“XX管理系统图形版”的项目出现其实就是在控制台的程序外面套了一层图形界面的壳。影响范围上EasyX官方文档和社区资源都很成熟网上能查到大量源码示例。我个人的体会是它特别适合用来训练“把逻辑和界面分开”的思维游戏逻辑是纯C/C代码渲染和交互交给图形库接口这个分层习惯对以后学任何图形框架都有帮助。2. 环境准备安装配置与工程选型2.1 安装流程与版本选择EasyX的安装极其简单去官网下载安装包里面是一个对应版本的文件下载后运行它会自动检测你机器上安装的Visual C版本勾选对应版本后点安装就完成了。整个过程不需要配置环境变量不需要复制DLL到系统目录它对新手友好得不像一个图形库。不过这里有几个细节我吃过亏所以特意说一下如果你机器上装了两三个版本的Visual Studio比如VS2017和VS2022并存安装程序会列出所有检测到的版本建议全部都勾上。不然你写了代码用没装库的那个版本编译会报一堆找不到头文件的错。64位系统上跑EasyX工程项目属性里的“平台”选x64还是Win32都能运行。库本身对两种平台都支持但如果你混用第三方库就要注意匹配。我自己做项目习惯统一用Release x64编译发布给别人的时候也省事。安装完之后要验证安装成功最简单的办法是新建一个空项目写上下面这段代码如果能弹出窗口并显示文字就说明环境没问题#include graphics.h #include conio.h int main() { initgraph(640, 480); settextstyle(40, 20, _T(宋体)); outtextxy(100, 100, _T(EasyX 环境测试 OK)); _getch(); closegraph(); return 0; }2.2 IDE工程选项配置要点很多新手在这个环节会卡住所以我多写几句。EasyX对IDE的要求很宽松VS全系都可以用Dev-C需要折腾一点Code::Blocks需要做Makefile配置我个人还是推荐用Visual Studio来学集成度最好调试也方便。工程创建之后有四个地方建议提前确认好配置项推荐设置说明字符集使用多字节字符集或Unicode均可默认情况下VS新项目是UnicodeEasyX兼容但用_T()宏包住字符串最保险SDL检查关闭SDL检查不关的话某些与main冲突的调用可能编译不过附加依赖项winmm.lib部分版本需要有些动画和声音API需要这个库平台工具集保持默认版本不需要为了EasyX降级工具集注意如果链接阶段报“无法解析的外部符号”多半是附加依赖项没加全。在工程属性-链接器-输入-附加依赖项里手动加上winmm.lib试一下这是最常见的一个坑。3. 核心API拆解我平时最常用的这批函数3.1 绘图基础坐标、颜色与画笔EasyX的坐标系统很好理解窗口左上角是原点(0,0)x轴向右为正y轴向下为正单位是像素。这和数学课上那种左下角原点、y向上为正的坐标系不一样初学时常有人把图形画反了其实只要记住屏幕坐标和数学坐标的差异多画几个点就有了手感。颜色上EasyX用16进制COLORREF表示颜色格式是0xBBGGRR和常见的0xRRGGBB顺序反着来知道有这回事就行平时直接用宏RGB(r,g,b)来生成颜色最省心RGB(255,0,0)是纯红色RGB(0,255,0)是纯绿RGB(0,0,255)是纯蓝。淡一点的颜色就是把三个分量调小比如粉红色是RGB(255,200,200)。绘图的核心理念是“先设置画笔/画刷再绘制图形”。EasyX里对应的机制是你可以设置当前画笔的颜色、线宽然后调用circle、rectangle、line这些函数去画。这种“画笔模式”跟Windows GDI的思路一致其实跟现实里拿笔在纸上作画也差不多——你先把笔换了画出来的线条就是这种风格。我最初写的图形程序经常有个问题画完一个图形后接着画下一个结果前一个的颜色“自动”变了。后来才明白不是图形库有问题是我在两次绘图之间改动了全局画刷设置。如果你想让每个图形颜色独立记得在每次绘制前重置画笔和画刷。3.2 常用绘图函数速查表先给一张我平日用得最多的函数表照着写不容易出错功能函数签名备注画点putpixel(int x, int y, COLORREF color)注意它需要指定颜色画大量点时性能一般画线line(int x1, int y1, int x2, int y2)使用当前画笔颜色和线宽画圆circle(int x, int y, int radius)只画轮廓不带填充填充圆solidcircle(int x, int y, int radius)画实心圆覆盖性好画矩形rectangle(int left, int top, int right, int bottom)对角线两个点确定矩形填充矩形solidrectangle(int left, int top, int right, int bottom)常用于障碍物、背景块画椭圆ellipse(int left, int top, int right, int bottom)以内切矩形的方式定义椭圆圆角矩形roundrect(int left, int top, int right, int bottom, int ellipseWidth, int ellipseHeight)做对话框、按钮很有用填充圆角矩形solidroundrect(...)与上面类似参数一样输出文字outtextxy(int x, int y, LPCTSTR str)需要配合settextstyle设置字体设置背景色setbkcolor(COLORREF color)配合cleardevice()使用这里有个小要点circle的第三个参数是半径不是直径。这个概念很基础但真的有人会传直径进去结果画出来的圆大了一圈。我还见过有人问“为什么circle画出来的圆自己会变形”其实是窗口宽高比例和屏幕像素宽高比不一致导致的视觉畸变不是函数的问题。3.3 窗口控制、刷新与双缓冲窗口相关API也是根基础但有几个细节容易被忽略initgraph(int width, int height)初始化指定大小的绘图窗口。函数还可以接受第三个参数用来设置窗口样式比如无边框。closegraph()关闭图形窗口释放绘图资源。注意这个函数和_getch()配合使用能实现“按任意键退出”的效果。如果忘了调用closegraph()有些环境会出现程序结束但窗口残留或黑屏的情况。cleardevice()用当前背景色清空整个绘图区域。每个新帧开始前你通常都要先调用它不然上一帧的图像会残留。BeginBatchDraw()/EndBatchDraw()/FlushBatchDraw()这是一组极重要的函数。简单来说批量绘制可以让所有绘图操作先发生在内存中调用FlushBatchDraw()时才一次性把画面呈现到屏幕上这样可以有效避免画面闪烁。双缓冲的原理我用一个生活化的例子讲手工黑板报如果边写边给同学看你写一笔大家看到一笔中间所有的“半成品状态”都暴露了非常乱。而先在一张草稿纸上把所有字画好最后直接把透明贴纸贴到黑板上大家看到的就永远是完整、干净的成品。BeginBatchDraw()就是在草稿纸上开工FlushBatchDraw()就是贴成品那一下。这也是游戏渲染不闪的基础逻辑。如果你发现自己程序里的图形疯狂闪烁先别怀疑电脑配置先检查自己有没有用BeginBatchDraw()和FlushBatchDraw()。我见过有人把这两个函数放在每次绘图的循环体里正确用法是循环外开启、循环里刷帧。3.4 图像贴图与透明处理EasyX的loadimage和putimage可以实现把图片文件bmp/jpg/png等加载并绘制到窗口。初学者做游戏最容易在这里踩“黑底盖住精灵”的坑。loadimage(IMAGE* dst, LPCTSTR file, int width 0, int height 0)从文件加载图片到IMAGE对象。putimage(int x, int y, IMAGE* src, DWORD dwRop SRCCOPY)把图片绘制到窗口指定位置。putimage的透明玩法当src画面里有某种 “关键色”通常是洋红色RGB(255,0,255)或黑色时可以通过设置dwRop参数为SRCAND或配合掩码图来实现透明效果。但是这种透明其实是很复古的做法现代做游戏建议直接用带Alpha通道的PNG图片配合EasyX的putimage加上TRANSPARENT参数也能实现半透明效果。我个人的做法是所有精灵图都统一导出成带透明通道的PNG加载后直接用省时省心只有一些老素材才用“关键色抠图”的方式。另外要注意loadimage一次只能加载一个图片对象如果游戏里有几十张图你需要管理好你的IMAGE数组或对象池反复load会拖慢速度而且容易内存泄漏。我的经验是把所有图片在initgraph后一次性load到一个数组里之后再putimage就只复制不读盘了。3.5 键盘鼠标输入处理一个交互式程序如果没有输入就只是个画板。EasyX处理输入的方式很朴实你可以在循环里去主动查“有没有按键”或者用消息函数“等”事件。_getch()等待一个键盘按键并返回其ASCII码在控制台时代就能用EasyX里依然有效。kbhit()非阻塞式检测“是否有键按下”有返回非0。MOUSEMSG结构体配合MouseHit()和GetMouseMsg()获取鼠标消息结构体里有x、y、uMsg字段uMsg是WM_LBUTTONDOWN这种消息标识。ExMessage结构体新版EasyX提供了更现代的消息机制一个结构体包含了消息类型、按键虚拟码、鼠标坐标、滚轮数据等。用peekmessage()可以非阻塞读取用getmessage()则会阻塞等待适合做“等用户点击”的流程。我在做小游戏的时候输入处理的代码通常写成这种“事件泵”的形态while (true) { // 循环体开始 flushmessage(EM_KEY | EM_MOUSE); // 可选的清空消息队列 ExMessage msg; while (peekmessage(msg)) { if (msg.message WM_KEYDOWN) { // 根据 msg.vkcode 处理按键 } else if (msg.message WM_LBUTTONDOWN) { // 鼠标左键点击msg.x 和 msg.y 是点击位置 } } // 游戏逻辑更新 // 绘制 FlushBatchDraw(); Sleep(10); // 简单控制帧率实际项目里推荐用时间戳 }这种消息泵的好处是不阻塞游戏循环一直跑不会因为“等用户按键”导致动画卡住。我见过很多新手写_getch()直接卡住整个游戏等按完键画面才动这就是阻塞式输入和游戏主循环冲突的典型例子。4. 实战做一个会动的俄罗斯方块引擎学完API最后一个实战环节我选了俄罗斯方块。为什么选它因为俄罗斯方块的核心逻辑是“图形块 边界判断 碰撞检测 消行计分”图形库用得最少但C/C语言的数组、循环、状态机用得特别扎实。做通这个项目你对EasyX的窗口管理、绘图、输入响应、刷新控制就全通了。4.1 游戏逻辑与数据建模俄罗斯方块的本质是维护一个“宽10格、高20格”的二维数组。每一格的值代表当前是否有方块占用0是空1或颜色值表示有方块。方块本身也是一个二维数组用0和1表示形状。我在做这个项目时最深的体会是图形库只负责把数组里的数据画出来真正的游戏性全在数组里。你写游戏的时候脑子里要砍成两个世界——逻辑世界是数组渲染世界是像素。这两个世界通过坐标映射关联起来。坐标映射公式很简单列的x像素 列号 * 格子大小 偏移行的y像素 行号 * 格子大小 偏移以格子大小30px为例第2列第3行的格子绘制的左上角就是(2*30, 3*30)。这个映射关系写清楚后后续所有碰撞检测和渲染都可以统一处理。4.2 方块碰撞与旋转的核心实现方块的碰撞检测核心就是“把方块的相对坐标加上全局偏移后逐格去检查目标数组区域能不能放进去”。伪代码如下bool canPlace(int blockType, int rotation, int gridX, int gridY) { for (int r 0; r 4; r) for (int c 0; c 4; c) { // 只处理方块占用为1的格子 if (shape[blockType][rotation][r][c] 0) continue; int boardRow gridY r; int boardCol gridX c; // 超出边界不可放置 if (boardCol 0 || boardCol BOARD_WIDTH) return false; if (boardRow BOARD_HEIGHT) return false; // 如果底部还没出界但目标位置已经有方块 if (boardRow 0 board[boardRow][boardCol] ! 0) return false; } return true; }旋转的实现有个常见的坑方块旋转后长宽比例变了碰撞框也要跟着变。我的做法是把所有方块含旋转形态都预生成成4×4的二维数组这样旋转逻辑就不再是复杂的坐标变换了而是一个“查表”rotation加1对4取余就能得到下一形态。这个方法代码量最少而且不容易算错。4.3 渲染关键代码与界面布局这一节是图形库的核心应用。窗口逻辑上分三块游戏区、下一个方块预览区、分数显示区。我的布局是游戏区左上角在(0,0)宽1030300px高2030600px预览区在游戏区右侧差不多(320,100)分数显示在(320,250)。渲染的代码思路是这样的void render() { cleardevice(); // 画背景格 setlinecolor(RGB(200, 200, 200)); for (int x 0; x BOARD_WIDTH; x) { line(x * GRID_SIZE, 0, x * GRID_SIZE, BOARD_HEIGHT * GRID_SIZE); } // 画已落地的方块 for (int row 0; row BOARD_HEIGHT; row) { for (int col 0; col BOARD_WIDTH; col) { if (board[row][col] ! 0) { setfillcolor(board[row][col]); solidrectangle(col * GRID_SIZE, row * GRID_SIZE, (col 1) * GRID_SIZE, (row 1) * GRID_SIZE); } } } // 画当前下落的方块 for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (curShape[r][c] ! 0) { int px (curGridX c) * GRID_SIZE; int py (curGridY r) * GRID_SIZE; setfillcolor(curColor); solidrectangle(px, py, px GRID_SIZE, py GRID_SIZE); } } } // 绘制预览与分数 // ... FlushBatchDraw(); }这段渲染代码有几个心得为了让每个方块看起来立体一点我习惯用setlinecolor加上浅色外框缝线具体是在每个solidrectangle外面套一个rectangle画边框。视觉效果好很多。在清屏之前记得setbkcolor不然后台缓存可能会用上一次的背景色。整体绘制顺序一定要稳定先画背景网格、再画已固定方块、再画移动中的方块。如果顺序乱了移动方块会被背景网格盖住出现“半透明”的错觉。4.4 计时器与帧率控制的实践很多初学者做俄罗斯方块会直接用Sleep(500)来控制下落速度。这在小项目里可行但有几个问题Sleep会阻塞整个线程导致按键响应的灵敏度变差了。下落速度想随关卡加快时调整Sleep的值不方便。不同机器上时间精度不一样Sleep的实际阻塞时长有波动。我的做法是用一个独立累加器在主循环里累加GetTickCount()或clock()的差值当累计时间超过“下落间隔”时才执行一次下落。DWORD lastTime GetTickCount(); while (true) { DWORD now GetTickCount(); DWORD elapsed now - lastTime; if (elapsed dropInterval) { // 执行下落 MoveDown(); lastTime now; } // 按键处理 // 渲染 Sleep(16); // 至少给系统一点喘息控制在大约60FPS }这种“时间戳驱动”的模式以后你学任何游戏引擎都会遇到先在这里养成习惯不亏。5. 常见问题与排错实录5.1 编译报错排雷我整理了一张常见编译错误表基本都是队友和群友问过我无数次的。报错现象常见原因解决方案fatal error C1083: Cannot open include file: graphics.h没安装EasyX或装的时候漏选了当前VS版本重装EasyX勾选全部版本无法解析的外部符号 _main该符号在函数...中被引用工程类型选了Windows应用程序但代码入口还是控制台的main把工程类型改成“控制台应用程序”无法解析的外部符号 _WinMain16工程类型是控制台但代码里没有main写了WinMain要么改工程类型要么把入口统一成mainerror C2664: “void std::...”: 不能将参数从“const char [..]”转换为“LPCTSTR”字符集不匹配或字符串没加_T()用_T(字符串)包住或把字符集改成多字节warning C4996: fopen was deprecated使用了一些老C函数在代码头加#define _CRT_SECURE_NO_WARNINGS这里特别说明一下_T()宏的意义。如果你工程是Unicode字符集abc这种字面量是窄字符而LPCTSTR在Unicode下是宽字符LPCWSTR类型不匹配就会报上面那个C2664错误。用_T(abc)后编译器会自动根据字符集把字符串替换成正确类型。这是从MFC时代传下来的老传统EasyX保留了兼容所以写代码时养成加_T()的习惯能省很多事。5.2 运行时表现异常排查图画出来了、但表现很怪这类问题也特别多。我按严重程度排一下窗口打开后黑屏或白屏最常见的原因是initgraph之后直接输出了文字但没有刷新画面。解决方法是输出文字之后加FlushBatchDraw()或者用Sleep(100)等待一帧。画面闪烁严重前面提到了没有用BeginBatchDraw()/FlushBatchDraw()。或者你把这俩函数放进了循环里而不是循环外。另外如果每一帧都cleardevice()再逐个重画也会闪改用批量绘制就解决了。方块掉落速度不规律时快时慢多半是因为你在下落逻辑里用了Sleep但不同帧里按键处理、绘制耗时不同。换成时间戳累加器就好。方块越界或穿模碰撞判定里的边界条件写错了或者旋转形态和实际碰撞框不匹配。这种问题要善用调试输出在逻辑循环里把当前方块的坐标、方向打印到控制台或窗口上一眼就看到了。窗口关不掉或退出异常closegraph()前有阻塞调用没返回比如getmessage()在等一个永远不来的消息。检查你的消息循环有没有在游戏结束时正确退出。5.3 关于EasyX的一些切身建议最后说点不那么技术、但很有用的个人经验。第一EasyX画的窗口不是真正的独立窗口它在底层还是依赖于控制台窗口所以运行时你会看到一个命令行窗口和一个图形窗口并存。这个外观对“发布给别人用”来说不太好看但作为学习和课设完全没问题。如果你要发布正式一点的程序后面可以考虑换个图形框架但学习期别折腾。第二EasyX的调试体验在VS里其实相当好。因为图形窗口和控制台可以并存你可以用printf往控制台打日志这对排查逻辑问题特别有帮助——图形程序最怕的就是看不出状态有控制台输出就踏实多了。我做的项目里控制台常年打印方块坐标、当前分数、消行数。第三用EasyX写游戏很快就你会发现游戏的灵魂不在“图形库好不好看”而在“逻辑封装”和“状态管理”。方块落地后怎么合并进棋盘、消行怎么处理、游戏结束怎么判断这些才是代码的精华。当你开始琢磨这些问题的时候说明你已经不是在学图形库而是在学做游戏了。最后再分享一个小技巧做任何图形项目一开始别追求华丽特效先用最简单的方形代表物体把逻辑跑通再把方块替换成图片。我在俄罗斯方块项目里方块一开始是纯色矩形颜色全靠RGB生成后来才加格线和阴影。这样思路不被打断调试也快。图形库终究是工具真正的功底永远是你的C/C底子和数据结构能力。