资讯详情

C语言贪吃蛇项目:EasyX图形库实现音乐、图片与美观界面

📅 2026/10/23 11:42:44 | 华诺云谱 👁 阅读
C语言贪吃蛇项目:EasyX图形库实现音乐、图片与美观界面
每次看到还有人用黑底白字的控制台写贪吃蛇我都想劝一句试试EasyX。这个C语言图形库装上以后贪吃蛇这个题目完全可以从“交作业”变成“拿得出手”的项目。今天要聊的项目标题就很直白——EasyX图形库、C语言贪吃蛇、音乐、图片、界面比较美观。这三个关键词基本概括了这类项目的核心游戏逻辑要跑通资源要能加载画面要能看。我把做这个项目过程中最值得讲的部分拆开适合刚学完C语言、想做图形界面小游戏的人也适合准备课程设计却不知道从哪下手的同学。很多人一听“图形界面”就以为要学某个大框架其实用EasyX就是给C语言开了个窗口把绘图、按键、鼠标这些东西封装成普通函数C语言基础够用就能写。贪吃蛇本身逻辑不难真正的难点在于怎么把图片、音乐、界面这些“课外内容”整合进来。这篇文章会从整体设计、核心逻辑、图片加载、音乐播放、实操代码、常见问题几个方面讲照着做你能得到一个能跑、有音乐、有图片、界面不丑的完整项目。1. 项目整体设计与技术选型1.1 为什么用EasyX而不是控制台先说个大实话控制台版的贪吃蛇不是不能写但写出来的东西几乎没法给人看。你可以在用Win32 API、SDL、OpenGL可对一个刚学完结构体和指针的C语言新手来说这些工具的学习曲线太陡。EasyX最大的价值是把“画点、画线、画矩形、贴图、播放声音”变成几行代码而且它专门面向C/C教学场景不需要你去怼窗口消息循环对新手极度友好。我用EasyX做过好几个小游戏最舒服的一点是它不改变你写C语言的思路。比如游戏循环还是while(1)按键检测还是_kbhit()和_getch()只是把printf换成了putimage、把system(cls)换成了cleardevice。这意味着你已有的C语言基础不会浪费学习成本基本集中在EasyX函数上。另外一个现实原因绝大多数学校课程设计允许用EasyX不需要额外配置复杂环境。VS里装好EasyX之后直接新建空项目、包含头文件就能写比配置SDL省太多事。1.2 项目功能模块拆解这个标题里的项目虽然看起来简单实际上包含了四个模块游戏基础逻辑、图片资源、音频资源、界面状态管理。拆开看就清晰了模块具体内容关键技术点游戏逻辑蛇身移动、吃食物、得分、碰撞检测数组或链表结构方向枚举随机数图片资源背景图、蛇头、蛇身、食物loadimage加载putimage绘制音频资源背景音乐、吃食物音效、结束音效MCI接口mciSendStringwinmm.lib界面状态开始菜单、游戏中、暂停、游戏结束状态机不同状态调用不同绘制函数我建议拿到需求后先别急着写代码先把功能拆成上面这种表格。贪吃蛇这类项目最容易出现的错误是逻辑和界面混在一起写最后改一个地方牵连整个文件。用状态机来管理界面把游戏逻辑和绘制分开后期加暂停、加难度都很方便。1.3 技术方案取舍图片素材还是纯绘图标题里写了“图片”和“界面比较美观”但图片不一定非要外置素材。我自己做的时候先用纯图形函数把界面画出来后面再换成图片资源这样有两个好处一是不会因为找不到素材卡住二是EasyX绘图函数本身也能实现不错的效果。比如蛇身不一定是绿色方块可以用solidroundrect画圆角矩形再根据蛇身索引调整颜色明暗画面立刻有层次感。食物可以用solidcircle加高光点背景可以setbkcolor配渐变色这些都不需要任何图片文件。如果你确实想用图片建议准备三种素材背景图、蛇头图、食物图。蛇身因为要分段移动用图片反而麻烦纯绘图更灵活。我的方案是“背景食物用图片蛇身用绘图”这样既保证了画面效果又不会把简单的事搞复杂。2. 核心逻辑实现贪吃蛇的数据模型与移动算法2.1 蛇身数据结构很多人写贪吃蛇第一反应是用链表因为蛇身长度会变。但在EasyX这种小项目里用固定数组完全够用而且代码简单得多。关键在于理解“数组当蛇身长度字段维护有效范围”。#define MAX_SNAKE_LEN 1000 typedef struct Point { int x; int y; } Point; typedef enum Dir { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT } Dir; typedef struct Snake { Point body[MAX_SNAKE_LEN]; int length; Dir dir; } Snake;用这个结构体之后蛇的每段身体就是一个坐标点。body[0]我建议存蛇头方便后面移动和碰撞判断。每次移动只需要做两件事把整个数组从末尾到头部依次后移一位然后根据当前方向算出新的蛇头坐标。用数组不用链表还有个原因显示的时候需要反复遍历蛇身数组随机访问比链表方便你还能轻松根据下标给不同片段画不同颜色。2.2 移动、生长与方向控制移动的核心代码很多人写得不对常见错误是先从头部开始移动结果下一段身体移动时把蛇头的新位置当成自己的新位置蛇身直接串位。正确做法是“从尾部往头部覆盖”。void moveSnake(Snake* snake) { // 从尾部往头部方向移动让每一段都等于前一段的旧位置 for (int i snake-length - 1; i 0; i--) { snake-body[i] snake-body[i - 1]; } // 更新蛇头坐标 switch (snake-dir) { case DIR_UP: snake-body[0].y - UNIT; break; case DIR_DOWN: snake-body[0].y UNIT; break; case DIR_LEFT: snake-body[0].x - UNIT; break; case DIR_RIGHT: snake-body[0].x UNIT; break; } }吃食物就更好办了吃到了就把length加1相当于尾部那一格没有删除下一次移动时数组整体后移自然就多出一格实现了生长。不需要真去“插入”一个节点。方向控制有个所有新手都会踩的坑当前方向是LEFT时不能按RIGHT。如果直接改方向下一次移动时蛇头立刻反向会和身体相撞。解决办法是记录一个“下一步方向”只允许垂直方向改变水平方向水平方向改变垂直方向。void changeDir(Snake* snake, Dir newDir) { // 防止直接反向 if ((snake-dir DIR_LEFT newDir DIR_RIGHT) || (snake-dir DIR_RIGHT newDir DIR_LEFT) || (snake-dir DIR_UP newDir DIR_DOWN) || (snake-dir DIR_DOWN newDir DIR_UP)) { return; } snake-dir newDir; }2.3 碰撞检测与游戏结束碰撞检测分两种撞墙和撞自己。撞墙的判断很简单只要蛇头的坐标超出窗口边界就结束。但要注意窗口边界坐标从0开始比如窗口宽度是800那么x的范围应该是[0, 800)超出即结束。我建议动态读取窗口宽度而不是写死方便后续改窗口大小。撞自己的判断需要用循环从body[1]开始遍历到body[length-1]看有没有坐标和body[0]重合。为什么从1开始因为头部本身肯定是重合的从自己开始比对没有意义。int checkCollision(Snake* snake, int width, int height) { Point head snake-body[0]; if (head.x 0 || head.x width || head.y 0 || head.y height) { return 1; } for (int i 1; i snake-length; i) { if (head.x snake-body[i].x head.y snake-body[i].y) { return 1; } } return 0; }实际项目里还要考虑一个问题食物不能生成在蛇身上。判断食物坐标是否落在蛇身任意一段上落在就重新生成。这个循环看起来不起眼但如果不处理食物偶尔会“消失”在蛇的身体下面玩家会一脸懵。3. 图片加载与界面美化实用方案3.1 图片素材的准备与格式选择提到图片很多同学第一反应是下载一堆高清图结果loadimage加载失败或者程序卡顿。EasyX使用loadimage加载图片时对普通BMP、JPG、PNG都支持但我想说一句素材不是越大越好。背景图宽高最好和窗口一致比如窗口800x600背景图也做成800x600加载时最多做一次缩放。如果你有PNG带透明通道EasyX可以直接显示透明效果这是最省事的。但网上很多素材是JPG带不了透明你就会看到白色或黑色背景块。我的解决办法是食物、小图标尽量找透明PNG背景用不透明图片蛇身这种需要灵活变形的部分干脆不贴图用绘图函数画。还有个细节EasyX默认支持的是ANSI编码如果你把项目设成Unicode字符集字符串常量都要用_T()包起来。为了避免麻烦我建议把项目字符集改成“使用多字节字符集”。这个问题不解决loadimage里面的路径怎么都加载不出来非常容易怀疑人生。3.2 loadimage和putimage的正确打开方式加载图片的API标准写法是#include graphics.h IMAGE bg; // 参数说明图片对象、路径、缩放宽、缩放高、是否调整尺寸 loadimage(bg, _T(res/bg.jpg), 800, 600, true);然后绘制putimage(0, 0, bg);这里最坑的是路径。C语言里反斜杠是转义字符所以如果用res\\bg.jpg或者是正斜杠res/bg.jpg。我习惯用正斜杠省得转义。另外loadimage第三个和第四个参数是缩放宽度和高度。很多教程直接不写图片就会按原始尺寸绘制。如果你的图片比窗口大可能只显示左上角一小块不是代码错是尺寸问题。第五个参数传true表示异步加载我印象里不是。看EasyX文档loadimage(IMAGE*, const TCHAR*, int width 0, int height 0, bool resize false)。resize表示是否调整图片尺寸到width和height。如果传true且宽高为0会调整到原始大小实际上我们一般这样用loadimage(bg, _T(bg.jpg), 800, 600, true);直接把背景图缩放成窗口大小简单直接。3.3 蛇身、食物与背景的绘制细节即使有图片蛇身的绘制也会影响整个界面的美观程度。如果只用填充矩形像素风很强但谈不上“美观”。我用的是圆角矩形加渐变颜色代码很简单void drawSnake(Snake* snake) { for (int i snake-length - 1; i 0; i--) { int x snake-body[i].x; int y snake-body[i].y; // 根据位置调整颜色蛇头亮、蛇尾暗 COLORREF color HSVtoRGB(120, 0.6, 0.8 - 0.4 * i / snake-length); setfillcolor(color); solidroundrect(x 2, y 2, x UNIT - 2, y UNIT - 2, 10, 10); } }注意我绘制时从尾部开始这样蛇头会最后绘制覆盖蛇身连接处的小缝隙画面会更顺眼。食物可以画成一个圆再加一个白色高光小圆立刻有立体感setfillcolor(RGB(220, 40, 40)); solidcircle(food.x UNIT / 2, food.y UNIT / 2, UNIT / 2 - 2); setfillcolor(RGB(255, 180, 180)); solidcircle(food.x UNIT / 2 - 3, food.y UNIT / 2 - 3, 4);背景图放上去之后最好不要在实际游戏区域直接使用putimage因为图片覆盖全窗口后会挡住其它文字。可以先把背景画到底层再在上面绘制蛇和食物。EasyX没有真正的图层概念你只需要按“背景→装饰→蛇→食物→文字”的顺序绘制就能达到类似图层的效果。3.4 多界面状态切换界面美观不光是图片好看还有整体流程。没有菜单直接进入游戏、死了直接关窗口这种体验确实很“课程设计”。我建议用一个枚举类型保存游戏状态typedef enum GameState { STATE_MENU, STATE_PLAYING, STATE_PAUSE, STATE_OVER } GameState;游戏循环先判断当前状态然后调用对应的绘制函数。菜单界面可以画一个标题文字再画“开始游戏”“退出”两个按钮。EasyX里可以监听鼠标消息用MOUSEMSG结构体读取鼠标位置判断是否点击在按钮区域。暂停界面最简单只需要在PLAYING状态下按空格键切换到PAUSE绘制的时候加一个半透明遮罩。这里有一个很多人会忽略的地方绘制遮罩时不要用getimage去截屏保存直接画一个半透明黑色矩形就行。EasyX没有内置半透明矩形可以用一个做法setfillcolor一个低透明度颜色配合fillrectangle但颜色本身不支持alpha。我的做法是先用一个带透明度的宏或者干脆让背景变暗通过画一个纯色深色矩形再画游戏文字视觉效果已经足够。游戏结束界面要显示出得分还要提示“按回车重新开始”。重新开始意味着重置蛇身、重置食物、重置得分这里最好封装成一个resetGame函数不要在循环里零散地赋值否则容易漏。4. 音乐与音效实现以及背后的MCI细节4.1 用mciSendString播放背景音乐C语言要播放音乐最省事的就是Windows的MCIMedia Control Interface接口。mciSendString相当于一个命令行控制台你给它传命令字符串它去操作音频设备。需要包含mmsystem.h并链接winmm.lib#include mmsystem.h #pragma comment(lib, winmm.lib)播放背景音乐就几步// 打开音乐文件取一个别名 mciSendString(_T(open bgm.mp3 alias bgm), NULL, 0, NULL); // 播放repeat表示循环 mciSendString(_T(play bgm repeat), NULL, 0, NULL);mciSendString的参数里有一个缓冲区和长度如果只是发送命令不需要返回值前三个参数可以传NULL和0。网上很多代码不检查返回值结果音乐没出来也不知道为什么。你可以把返回值接住用GetErrorText查错误信息但大部分情况是路径问题或格式问题。有个细节MCI播放MP3需要你的系统有对应的解码器Windows自带的Media Player能播的MP3一般都能用。如果遇到播不了换WAV格式最保险WAV的兼容性最好缺点是文件大。背景音乐用MP3音效用WAV是最稳的组合。4.2 吃食物音效与场景切换音效背景音乐可以一直循环但吃食物音效每次触发都要重新播放。如果只调用一次play第二次吃食物时音乐可能在播放过程中不会重新响。解决方法是先把同名设备关闭再重新打开播放void playEatSound() { mciSendString(_T(close eat), NULL, 0, NULL); mciSendString(_T(open eat.wav alias eat), NULL, 0, NULL); mciSendString(_T(play eat), NULL, 0, NULL); }这种方式每次关闭再打开稍微有点延迟但对吃食物这种短音效来说完全体感不到。如果你有多个音效可以为每个音效起不同的别名比如eat、over、click避免相互抢占设备。游戏结束时播放一个低沉音效再关闭背景音乐体验会完整很多mciSendString(_T(close bgm), NULL, 0, NULL); mciSendString(_T(open over.wav alias over), NULL, 0, NULL); mciSendString(_T(play over), NULL, 0, NULL);4.3 音乐模块常见坑音频这块坑非常多我遇到过几个典型情况。第一个是文件路径。mciSendString里的路径同样是字符串一样有反斜杠转义问题。如果文件放在项目根目录直接写文件名就行放在res目录就要写res/bgm.mp3。路径中有空格时MCI命令会解析错误需要给完整路径加双引号比如mciSendString(_T(open \res/my bgm.mp3\ alias bgm), NULL, 0, NULL);第二个是“无声”现象。有时候代码没报错但就是听不到声音可能是音量被系统混音器静音也可能是没有声卡设备。这时候先拿系统播放器确认文件能播放再检查MCI命令返回。排查时可以在播放前后打印返回值返回非0基本就是命令错误。第三个是关闭程序时背景音乐还在响。因为Windows的MCI设备是独立的你需要在程序退出前发送close命令或者直接调用mciSendString(close all, NULL, 0, NULL);。这既是礼貌也让开发者模式调试时不会出现“上一个程序的音乐还在后台响”的情况。5. 实操过程从零搭建一个带音乐图片的贪吃蛇5.1 环境配置先装EasyX。去官网下载安装包选择你当前VS版本安装器会自动把库文件放到对应目录。然后新建一个C空项目注意虽然你写的是C语言代码但VS里用空项目加.cpp文件也能编译纯C语法EasyX官方推荐用C项目因为图形库内部用了C特性你也可以建C项目但需要把文件后缀改成.cpp或者调整配置我建议直接用空项目源文件放.cpp代码语法按C写。项目属性里把字符集改成“使用多字节字符集”这能避免很多TCHAR和_T()的兼容问题。当然如果你习惯用_T()宏那也可以保持Unicode但新手阶段还是少一个坑是一个。包含头文件建议这样写#include graphics.h #include conio.h #include stdio.h #include stdlib.h #include time.h #include windows.h #include mmsystem.h #pragma comment(lib, winmm.lib)graphics.h是EasyX主头文件conio.h里有_kbhit和_getch这两个函数用于非阻塞按键检测windows.h提供Sleepmmsystem.h和winmm.lib负责音乐。5.2 初始化窗口与资源加载代码结构分成两部分init函数负责初始化resetGame函数负责每局开始时的数据重置。#define WIDTH 800 #define HEIGHT 600 #define UNIT 20 IMAGE bg; IMAGE foodImg; Snake snake; Point food; int score; GameState state; void init() { initgraph(WIDTH, HEIGHT); srand((unsigned)time(NULL)); loadimage(bg, _T(res/bg.jpg), WIDTH, HEIGHT, true); loadimage(foodImg, _T(res/food.png), UNIT, UNIT, true); mciSendString(_T(open res/bgm.mp3 alias bgm), NULL, 0, NULL); mciSendString(_T(play bgm repeat), NULL, 0, NULL); resetGame(); }注意loadimage之后最好判断一下图片是否加载成功可以用一个简单的办法IMAGE对象初始时是空白加载后可以通过IsEmpty判断。不过很多教程不判断图片路径错了会在putimage时报错或者显示花屏。我习惯在加载失败时弹出一个消息框提示if (loadimage(bg, _T(res/bg.jpg), WIDTH, HEIGHT, true) NULL) { MessageBox(NULL, _T(背景图片加载失败), _T(错误), MB_OK); }5.3 游戏循环主体框架游戏循环是项目的骨架每个状态都要在循环里被处理。我写了一个最常用的版本while (1) { BeginBatchDraw(); cleardevice(); putimage(0, 0, bg); switch (state) { case STATE_MENU: drawMenu(); break; case STATE_PLAYING: processInput(); moveSnake(snake); if (checkFood()) score; if (checkCollision(snake, WIDTH, HEIGHT)) state STATE_OVER; drawGame(); break; case STATE_PAUSE: drawGame(); drawPause(); break; case STATE_OVER: drawOver(); break; } EndBatchDraw(); Sleep(80); }BeginBatchDraw和EndBatchDraw是EasyX的批绘制函数用来防止闪烁。原理很简单先不往屏幕上输出把所有绘制命令放到内存缓冲区最后一次性更新屏幕。虽然这里每个分支只画一帧但批绘制能明显减少画面跳动。这里没有把按键处理放外面是因为菜单和游戏中的按键逻辑不同。比如在菜单界面按回车进入游戏在游戏界面按空格暂停。如果你把所有按键处理集中在一个函数再根据不同state调用代码会很混乱。分开写更容易维护。5.4 速度控制与帧率思路Sleep(80)表示每一帧间隔80毫秒也就是蛇大约每秒移动12.5格。这个速度对新手玩家来说比较舒服。但Sleep存在一个隐患如果电脑性能波动或者你跑了很多东西帧率会不稳定。更专业的做法是记录上一帧时间计算时间差超过固定阈值才更新逻辑。我做过一个小改进用分数控制速度。初始Sleep(120)每吃5个食物减少5毫秒最低到30毫秒。这样游戏难度是递增的玩家有挑战性。但要注意Sleep值不能太小否则键盘输入和绘制可能跟不上速度太快反而显得卡。如果你想让画面更流畅可以把逻辑更新和绘制分离逻辑更新按固定步长绘制每次循环都执行。但EasyX项目里这么做有点小题大做反而把代码搞复杂。先按Sleep方案跑通再考虑优化即可。6. 常见问题与排错体验6.1 方向键问题一按方向键蛇就死这个问题的根源几乎都是没有做反向禁止。当前方向是LEFT你按RIGHT下次移动时蛇头直接穿到下一格身体上撞自己死了。解决办法就是写changeDir函数像前面代码那样判断。还有一个细节如果一帧内连续按了两个方向键比如先按UP再按LEFT最终方向取决于最后那个按键但下一次移动只发生一次中间不判断第二次移动就不会反向自杀。如果你用了_getch()检测方向键注意方向键会返回两个字节需要连续读两次。第一次返回值是224小键盘或0方向键第二次才是具体方向。很多新手第一次读到的值就拿来用结果方向键完全没反应。6.2 食物生成在蛇身里导致画面诡异食物随机生成后理论上可能落在蛇身体的任何一个坐标上。如果不检查玩家费劲追到那个位置结果食物被蛇身盖住看起来就像食物消失了。生成食物的函数要写个循环void generateFood() { do { food.x rand() % (WIDTH / UNIT) * UNIT; food.y rand() % (HEIGHT / UNIT) * UNIT; } while (isFoodOnSnake(snake, food)); }这里要用do-while而不是先判断再生成因为第一次无论如何都要生成。如果蛇已经很长几乎占满整个窗口循环可能会卡住。我实测过窗口800x600、UNIT20有1200个格点蛇再长也很少超过300碰撞概率可控不会死循环。不过严谨一点可以加一个最大尝试次数超过就重置位置到第一个空位。6.3 图片和音乐路径总是加载失败我在第3章已经强调过路径问题但这里再单独列出来因为它是最高频错误。VS编译后程序的工作目录默认是项目文件所在的目录但有时候你调试时设置的是解决方案目录就差了层。如果你把图片放在res文件夹但运行时报错找不到文件第一步先在代码里输出一下当前工作目录TCHAR buf[256]; GetCurrentDirectory(sizeof(buf), buf); OutputDebugString(buf);然后在对应位置放文件。最简单粗暴的做法是把图片和音乐直接放到项目目录不建子文件夹等程序跑通再整理。调试阶段不要折腾路径不然会浪费大量时间。6.4 窗口闪烁、画面拖影没有用BeginBatchDraw或者用了但每帧都调用cleardevice会导致画面闪烁。正确姿势是BeginBatchDraw和EndBatchDraw成对出现中间所有绘制操作写完后统一刷新。如果你发现还是闪检查你是否在绘制函数内部又调用了一次cleardevice反复清屏就会出现白色闪动。还有种情况背景图加载失败cleardevice清成黑色再加白太刺眼。先处理图片加载再看闪烁。6.5 编译报错找不到mmsystem.h或winmm.lib这个问题多见于没有添加#pragma comment(lib, winmm.lib)或者项目环境不是Windows平台。EasyX和winmm都依赖Windows SDK只要你的VS能创建Win32项目就不会缺这个头文件。检查项目属性里是否有包含目录被手动清空或者平台选择了Linux。确认平台设置为x86/x64不要选成ARM或其他平台。6.6 中文字体显示乱码或字体没效果settextstyle里设置中文字体名比如“楷体”“微软雅黑”如果用Unicode字符集字体名字符串最好用_T()包起来。显示中文时如果源文件是UTF-8编码在默认ANSI编译环境下容易乱码。建议源文件另存为ANSI编码最简单是GB2312或者使用settextstyle后再配合setbkmode(TRANSPARENT)让文字背景透明。乱码和字体效果是两回事字体不生效往往是因为拼错字体名系统里没有对应字体。做这个项目的时候我有一个很深的体会贪吃蛇本身逻辑很成熟网上代码一抓一大把但真正区分项目好坏的是资源加载、界面状态切换、音频管理这些“容易忽略的细节”。把这些细节处理到位项目质量立刻上了一个档次。最后再分享一个小技巧把每一步的关键函数单独封装不要全都堆在main函数里。我见过太多人把所有逻辑塞进一个while里后面想改暂停、改图片都要从头捋一遍很痛苦。你按我说的模块化写法改功能只是换一个函数的事后面扩展自动寻路、双人模式、最高分存档都会方便得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑