尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++与EasyX图形库实战:从零构建可扩展贪吃蛇游戏

C++与EasyX图形库实战:从零构建可扩展贪吃蛇游戏 1. 项目概述与核心价值最近在整理旧项目时翻出了当年用C和EasyX图形库手搓的一个彩色贪吃蛇。这几乎是每个C初学者在接触图形编程后都会尝试去实现的“里程碑”式小项目。它远不止是屏幕上一条移动的线段和几个方块那么简单而是涵盖了游戏循环、状态管理、用户输入、碰撞检测、图形渲染等多个核心编程概念的绝佳练手素材。网上源码虽多但很多要么逻辑混乱要么注释不清对新手理解背后的设计思路帮助有限。所以我决定把这个项目从头到尾、从里到外重新梳理一遍用一篇长文把如何从零开始用C和EasyX构建一个结构清晰、可扩展性强的彩色贪吃蛇游戏的完整过程讲透。本文不仅会提供一份详尽、可直接编译运行的完整源码更重要的是我会拆解每一个关键决策背后的“为什么”比如为什么用vector而不用数组管理蛇身为什么游戏循环要那样设计以及我在调试过程中踩过的那些“坑”。无论你是刚学完C基础语法想找个项目练手还是对游戏开发的基本框架感到好奇这篇文章都能给你提供一个扎实的、可复现的实践路径。2. 整体架构与设计思路拆解在动手写代码之前先花点时间想清楚整个游戏的运行逻辑和代码结构这能避免后期陷入混乱的调试。一个经典的贪吃蛇游戏其核心无非是几个状态的循环等待用户输入 - 根据输入更新蛇的状态移动、转向 - 检查碰撞撞墙、撞自己、吃食物 - 根据碰撞结果更新游戏状态蛇身增长、游戏结束、得分增加 - 渲染最新画面。我们的代码结构就要清晰地映射这个逻辑。2.1 核心模块划分我倾向于将整个项目划分为几个相对独立的模块这能让代码更易读、易维护图形与窗口模块负责初始化EasyX图形窗口设置背景、绘制蛇、食物、分数等所有视觉元素。这部分应该尽量“纯净”只关心“画什么”不关心“为什么画”。游戏逻辑模块这是游戏的大脑。它管理着蛇的坐标队列、移动方向、食物的位置、当前得分、游戏是否结束等所有状态。输入处理键盘事件也在这里响应并转化为对游戏状态的修改。主循环与定时模块游戏需要以固定的频率比如每秒10帧更新和渲染。我们需要一个稳定的循环在每次循环中依次调用逻辑更新和图形渲染。同时要控制蛇的移动速度使其不受电脑性能的过度影响。2.2 数据结构选型为什么是std::vector和std::pair蛇的身体本质上是一个动态增长的坐标序列。C中我们有几种选择原生数组、std::array、std::vector、std::deque甚至std::list。原生数组/std::array大小固定。蛇身会增长所以不合适。std::list增删效率高但随机访问慢。我们通常只需要在头部添加前进、在尾部删除如果没吃到食物或者遍历所有节点进行绘制和碰撞检测。list的遍历开销相对vector稍大。std::deque双端队列头尾增删效率都高是一个不错的选择。std::vector动态数组。在尾部增删效率是O(1)虽然头部插入效率低但我们的蛇移动逻辑可以通过一种“技巧”来避免头部插入——这个后面会详细说。vector在内存中是连续的遍历绘制、碰撞检测速度极快缓存友好。综合考虑实现的简洁性和性能我选择了std::vectorstd::pairint, int来存储蛇身。pairint, int可以方便地存储一个点的(x, y)坐标。选择vector意味着我们需要在移动逻辑上动点脑筋。注意这里有一个常见的误解认为蛇移动就一定要在头部插入新坐标尾部删除旧坐标。对于vector头部插入(insert(v.begin(), ...))效率确实低。但我们可以换一种思路先根据当前方向计算出蛇头“下一个”位置将其插入向量尾部然后检查是否吃到食物。如果没吃到就删除向量的第一个元素即旧的蛇尾。这样所有的增删操作都在尾部进行完美契合vector的高效特性。蛇的“头”始终是向量的最后一个元素(v.back())蛇的“尾”是向量的第一个元素(v.front())。2.3 游戏状态与流程设计我们需要定义几个关键的游戏状态例如MENU菜单、PLAYING游戏中、PAUSED暂停、GAME_OVER游戏结束。主循环会根据当前状态决定执行哪部分逻辑。例如在PLAYING状态下才会处理键盘输入更新蛇的移动方向并进行碰撞检测。游戏的核心流程伪代码如下初始化图形窗口 初始化游戏状态蛇、食物、分数、方向等 设置游戏状态为 PLAYING while (游戏未退出) { 获取当前时间戳 if (到达设定的帧间隔时间) { 清空上一次的屏幕 switch (当前游戏状态) { case PLAYING: 处理键盘输入仅改变方向不直接移动 根据方向计算新蛇头位置 进行碰撞检测撞墙、撞自己、吃食物 根据检测结果更新蛇身向量、分数、食物位置或游戏状态 break; case GAME_OVER: 显示游戏结束画面并等待用户按键重新开始或退出 break; // ... 其他状态 } 根据最新的游戏状态绘制所有元素蛇、食物、分数、边框等 刷新显示到屏幕 更新上一次时间戳 } 处理窗口消息如点击关闭按钮 }这个循环结构是游戏编程的通用模式理解了它就掌握了大部分简单游戏的核心。3. 核心细节解析与实操要点3.1 EasyX图形库的初始化与基础绘图EasyX是一个为C/C提供的简易图形库特别适合初学者在Windows环境下进行图形编程。它的API风格类似古老的Turbo C的graphics.h但更现代。初始化与关闭#include graphics.h // EasyX头文件 #include conio.h int main() { // 初始化一个640x480的图形窗口 initgraph(640, 480); // 设置背景色为深灰色 setbkcolor(DARKGRAY); // 用背景色清空屏幕 cleardevice(); // ... 你的游戏逻辑和绘图代码 ... // 按任意键关闭图形窗口 _getch(); closegraph(); return 0; }实操心得initgraph的第三个参数可以指定窗口样式比如SHOWCONSOLE可以同时显示控制台窗口方便用printf进行调试这在开发初期非常有用。基本图形绘制 贪吃蛇主要由方块组成EasyX提供了rectangle和fillrectangle来绘制矩形边框和填充矩形。为了画出圆角或更美观的蛇身我们可以用fillroundrect。// 设置当前绘图颜色为亮绿色 setlinecolor(LIGHTGREEN); setfillcolor(LIGHTGREEN); // 在坐标(x, y)处绘制一个边长为20像素的填充方块 // fillrectangle(left, top, right, bottom) fillrectangle(x * CELL_SIZE, y * CELL_SIZE, (x 1) * CELL_SIZE, (y 1) * CELL_SIZE); // 绘制一个圆角填充矩形视觉效果更佳 fillroundrect(x * CELL_SIZE, y * CELL_SIZE, (x 1) * CELL_SIZE, (y 1) * CELL_SIZE, 5, 5); // 最后两个参数是圆角的椭圆宽度和高度这里引入了一个重要的概念CELL_SIZE格子大小。我们通常将游戏区域划分为一个网格比如20x15个格子每个格子是一个逻辑单元。蛇和食物都占据一个或多个完整的格子。CELL_SIZE定义了每个格子在屏幕上的像素大小例如30像素。这样蛇的坐标(x, y)是逻辑坐标乘以CELL_SIZE后得到的就是屏幕上的实际像素坐标。这种抽象让碰撞检测和逻辑计算变得非常简单只需要比较整数坐标也便于调整游戏分辨率。3.2 蛇的移动与转向逻辑实现这是游戏逻辑的核心。我们需要存储蛇的当前移动方向上、下、左、右并在每一帧根据这个方向计算出新的蛇头位置。方向定义与输入处理enum Direction { UP, DOWN, LEFT, RIGHT, NONE }; Direction dir RIGHT; // 初始方向向右 // 在游戏主循环的输入处理部分 if (_kbhit()) { // 检查是否有键盘输入 char key _getch(); // 获取按键注意_getch()会阻塞在游戏循环中要用_kbhit()先判断 switch (key) { case w: case W: case 72: // 72是方向上键的扫描码 if (dir ! DOWN) dir UP; // 关键不能直接反向 break; case s: case S: case 80: if (dir ! UP) dir DOWN; break; case a: case A: case 75: if (dir ! RIGHT) dir LEFT; break; case d: case D: case 77: if (dir ! LEFT) dir RIGHT; break; case : // 空格键暂停 gameState PAUSED; break; } }关键点处理方向输入时必须禁止蛇直接反向移动例如从向上瞬间变为向下这会导致蛇头立刻撞到第二节身体游戏瞬间结束。所以加上了if (dir ! DOWN)这样的判断。计算新蛇头位置与更新蛇身 假设蛇身存储在vectorpairint, int snake中snake.back()是当前蛇头。// 获取当前蛇头坐标 pairint, int head snake.back(); pairint, int newHead head; // 根据方向计算新蛇头坐标 switch (dir) { case UP: newHead.second--; break; // y坐标减小 case DOWN: newHead.second; break; // y坐标增加 case LEFT: newHead.first--; break; // x坐标减小 case RIGHT: newHead.first; break; // x坐标增加 case NONE: break; // 游戏刚开始可能没有方向 } // 将新蛇头坐标加入向量尾部 snake.push_back(newHead); // 检查是否吃到食物假设food是食物的坐标pair if (newHead.first ! food.first || newHead.second ! food.second) { // 没吃到食物蛇身长度不变需要移除尾部第一个元素 snake.erase(snake.begin()); } else { // 吃到了食物生成新的食物分数增加 generateNewFood(); score 10; }这段代码清晰地体现了之前提到的用vector模拟蛇移动的技巧总是在尾部添加新头只在没吃到食物时删除旧尾。这样吃到食物时erase操作不会执行蛇身向量长度自然增加1实现了蛇的生长。3.3 碰撞检测的精细处理碰撞检测需要处理三种情况撞墙、撞自己、吃到食物。撞墙检测判断新蛇头newHead的坐标是否超出了游戏区域的边界。if (newHead.first 0 || newHead.first GRID_WIDTH || newHead.second 0 || newHead.second GRID_HEIGHT) { gameState GAME_OVER; }GRID_WIDTH和GRID_HEIGHT是游戏区域的逻辑格子尺寸。撞自身检测遍历蛇身向量从开头到倒数第二个元素因为最后一个元素是新头检查是否有任何一节身体的坐标与新蛇头坐标相同。// 注意循环结束条件是 i snake.size() - 1避开了刚添加的新头自身 for (size_t i 0; i snake.size() - 1; i) { if (snake[i].first newHead.first snake[i].second newHead.second) { gameState GAME_OVER; break; } }常见坑点新手容易写成遍历整个snake向量这会导致新蛇头永远和自己“碰撞”。必须排除蛇头自身。吃食物检测这个已经在移动逻辑里做了就是简单的坐标相等判断。更复杂一点的可以支持食物占用多个格子或者食物有特殊效果。3.4 食物的随机生成算法生成食物需要满足两个条件1. 位置必须在游戏网格内2. 位置不能与当前蛇身任何一节重叠。一个简单但低效的方法是循环随机生成坐标直到找到一个空闲位置。当蛇身很长时这可能会有多次碰撞检查。void generateNewFood() { bool onSnake; do { onSnake false; food.first rand() % GRID_WIDTH; food.second rand() % GRID_HEIGHT; for (const auto segment : snake) { if (segment.first food.first segment.second food.second) { onSnake true; break; } } } while (onSnake); }优化思路对于大型网格和长蛇可以预先计算所有空闲格子的列表然后从中随机选取一个。但这对于贪吃蛇这种规模的项目简单的循环随机通常已经足够快。别忘了在main函数开头用srand((unsigned)time(NULL))初始化随机数种子否则每次运行的食物位置序列都一样。4. 完整实现与代码组织下面我将一个模块清晰、注释完整的版本拆解开来。为了控制篇幅这里展示核心部分完整源码文件可以在文章结尾找到链接。4.1 常量定义与全局状态将游戏配置定义为常量便于调整。游戏状态变量设为全局对于小项目可以接受更好的做法是封装成类。// 游戏配置 const int GRID_WIDTH 20; // 网格逻辑宽度 const int GRID_HEIGHT 15; // 网格逻辑高度 const int CELL_SIZE 30; // 每个格子的像素大小 const int SCREEN_WIDTH GRID_WIDTH * CELL_SIZE; const int SCREEN_HEIGHT GRID_HEIGHT * CELL_SIZE; const int FPS 10; // 游戏帧率控制蛇速 // 颜色定义 (EasyX颜色常量) const COLORREF COLOR_SNAKE_HEAD LIGHTGREEN; const COLORREF COLOR_SNAKE_BODY GREEN; const COLORREF COLOR_FOOD RED; const COLORREF COLOR_BG DARKGRAY; const COLORREF COLOR_WALL BROWN; const COLORREF COLOR_TEXT YELLOW; // 游戏状态枚举 enum GameState { MENU, PLAYING, PAUSED, GAME_OVER }; // 全局游戏状态 GameState gameState PLAYING; Direction currentDir RIGHT; std::vectorstd::pairint, int snake; std::pairint, int food; int score 0; bool isRunning true; // 主循环控制4.2 核心函数实现初始化游戏函数void initGame() { // 清空蛇身并初始化蛇的起始位置例如在屏幕中央水平放置3节 snake.clear(); int startX GRID_WIDTH / 2; int startY GRID_HEIGHT / 2; snake.push_back({startX - 2, startY}); // 尾部 snake.push_back({startX - 1, startY}); // 身体 snake.push_back({startX, startY}); // 头部 currentDir RIGHT; score 0; generateNewFood(); // 生成第一个食物 }绘制函数void drawGame() { cleardevice(); // 清屏 // 1. 绘制墙壁/边界可选也可以直接利用碰撞检测 setlinecolor(COLOR_WALL); setfillcolor(COLOR_BG); rectangle(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); // 2. 绘制蛇 for (size_t i 0; i snake.size(); i) { int x snake[i].first; int y snake[i].second; if (i snake.size() - 1) { // 蛇头用不同颜色 setfillcolor(COLOR_SNAKE_HEAD); } else { setfillcolor(COLOR_SNAKE_BODY); } // 绘制圆角方块更美观 fillroundrect(x * CELL_SIZE 1, y * CELL_SIZE 1, (x 1) * CELL_SIZE - 1, (y 1) * CELL_SIZE - 1, 5, 5); } // 3. 绘制食物 setfillcolor(COLOR_FOOD); // 可以画成圆形更像苹果 solidcircle(food.first * CELL_SIZE CELL_SIZE/2, food.second * CELL_SIZE CELL_SIZE/2, CELL_SIZE/2 - 2); // 4. 绘制分数 settextcolor(COLOR_TEXT); settextstyle(20, 0, _T(Consolas)); TCHAR scoreStr[20]; _stprintf_s(scoreStr, _T(Score: %d), score); outtextxy(10, 10, scoreStr); // 5. 根据游戏状态绘制额外信息如暂停、游戏结束 if (gameState PAUSED) { settextcolor(LIGHTRED); settextstyle(30, 0, _T(黑体)); outtextxy(SCREEN_WIDTH/2 - 50, SCREEN_HEIGHT/2 - 15, _T(游戏暂停)); settextstyle(15, 0, _T(宋体)); outtextxy(SCREEN_WIDTH/2 - 80, SCREEN_HEIGHT/2 20, _T(按空格键继续)); } else if (gameState GAME_OVER) { settextcolor(LIGHTRED); settextstyle(30, 0, _T(黑体)); outtextxy(SCREEN_WIDTH/2 - 70, SCREEN_HEIGHT/2 - 40, _T(游戏结束)); _stprintf_s(scoreStr, _T(最终得分: %d), score); settextstyle(20, 0, _T(Consolas)); outtextxy(SCREEN_WIDTH/2 - 60, SCREEN_HEIGHT/2, scoreStr); settextstyle(15, 0, _T(宋体)); outtextxy(SCREEN_WIDTH/2 - 100, SCREEN_HEIGHT/2 40, _T(按R键重新开始按ESC退出)); } }主游戏逻辑更新函数void updateGame() { if (gameState ! PLAYING) return; // 1. 处理输入方向键 processInput(); // 2. 计算新蛇头位置 auto newHead snake.back(); switch (currentDir) { case UP: newHead.second--; break; case DOWN: newHead.second; break; case LEFT: newHead.first--; break; case RIGHT: newHead.first; break; case NONE: return; // 无方向时不移动 } // 3. 碰撞检测 // 3.1 撞墙检测 if (newHead.first 0 || newHead.first GRID_WIDTH || newHead.second 0 || newHead.second GRID_HEIGHT) { gameState GAME_OVER; return; } // 3.2 撞自身检测排除蛇头自身 for (size_t i 0; i snake.size() - 1; i) { if (snake[i] newHead) { // pair可以直接比较 gameState GAME_OVER; return; } } // 4. 移动蛇身添加新头 snake.push_back(newHead); // 5. 吃食物检测与处理 if (newHead food) { // 吃到食物分数增加生成新食物 score 10; generateNewFood(); // 注意吃到食物不删除蛇尾蛇身长度1 } else { // 没吃到食物删除蛇尾保持长度不变 snake.erase(snake.begin()); } }主循环int main() { // 初始化图形窗口 initgraph(SCREEN_WIDTH, SCREEN_HEIGHT, SHOWCONSOLE); // 显示控制台用于调试 setbkcolor(COLOR_BG); cleardevice(); // 初始化随机数种子 srand((unsigned)time(NULL)); // 初始化游戏 initGame(); // 游戏主循环 DWORD lastTime GetTickCount(); // 用于帧率控制 while (isRunning) { // 处理窗口消息如点击关闭按钮 if (peekmessage(msg, EX_KEY)) { if (msg.message WM_KEYDOWN) { if (msg.vkcode VK_ESCAPE) isRunning false; } } DWORD currentTime GetTickCount(); // 控制游戏更新频率 if (currentTime - lastTime 1000 / FPS) { updateGame(); drawGame(); lastTime currentTime; } // 处理游戏结束后的按键重新开始 if (gameState GAME_OVER) { if (_kbhit()) { char key _getch(); if (key r || key R) { initGame(); gameState PLAYING; } else if (key 27) { // ESC isRunning false; } } } Sleep(1); // 让出一点CPU时间 } closegraph(); return 0; }5. 常见问题、调试技巧与功能扩展5.1 编译与运行环境搭建问题1找不到graphics.h头文件或链接错误。这是最常见的问题。EasyX不是标准库需要单独安装和配置。解决方案前往EasyX官网下载安装包。运行安装程序它会自动检测你系统上的Visual Studio或Dev-C等IDE并将库文件配置好。对于Visual Studio创建一个新的空项目或控制台项目直接在源文件中#include graphics.h即可。注意项目属性中“字符集”的设置如果使用outtextxy输出中文建议将项目属性 - 配置属性 - 高级 - 字符集 改为“使用多字节字符集”或者使用Unicode宽字符版本_T(“文字”)。对于Code::Blocks、Dev-C等需要在编译器设置中添加库的链接。通常安装程序会帮你做好。问题2游戏窗口一闪而过。解决方案closegraph()之前或者主循环退出前添加一个等待按键的语句如_getch()。在我们的主循环中按ESC键会退出游戏结束后按R键重启已经包含了交互逻辑。5.2 游戏逻辑调试技巧问题3蛇的移动不流畅或者方向控制有延迟/失灵。原因分析通常是因为输入处理放在了基于帧率的更新循环之外或者使用了阻塞式的_getch()而没有用_kbhit()判断。排查方法确保processInput函数在每一次updateGame之前都被调用。并且使用if (_kbhit()) { key _getch(); ... }的模式。问题4蛇会“穿墙”或者食物生成在蛇身体里。原因分析撞墙检测的逻辑条件写反了或者食物生成算法没有正确检查与蛇身的重叠。调试技巧在碰撞检测和食物生成的位置添加调试输出。利用SHOWCONSOLE模式用printf或std::cout将蛇的坐标、食物坐标、碰撞判断结果实时打印到控制台。这是定位逻辑错误最直接的方法。问题5游戏速度不稳定在不同性能电脑上快慢不一。解决方案这就是我们使用GetTickCount()或clock()进行帧率控制的原因。通过计算两帧之间的实际时间差确保无论电脑多快或多慢updateGame和drawGame被调用的频率都是稳定的由FPS常量控制。这是游戏编程的基础知识。5.3 功能扩展与优化建议一个基础版本完成后你可以尝试添加更多功能让游戏更有趣也更能锻炼编程能力图形与音效升级精美贴图用loadimage和putimage函数替换简单的色块为蛇头、蛇身、食物加载图片。动画效果蛇移动时加入平滑的过渡动画吃食物时加入缩放或粒子效果。这需要更精细的时间控制和图形绘制。背景音乐与音效使用mciSendString函数播放简单的背景音乐和吃食物、撞墙的音效。游戏机制丰富多种食物生成不同颜色的食物对应不同分数或效果加速、减速、穿墙、缩短等。障碍物在场景中随机生成固定的障碍物墙。关卡系统随着分数增加提高蛇的移动速度(FPS)或者缩小网格增加难度。计分板与历史记录将最高分保存到本地文件。代码结构优化面向对象重构将Snake、Food、Game分别封装成类使代码更清晰。例如class Snake { private: std::vectorPoint body; Direction dir; public: void move(); void grow(); bool checkCollision(const Point point) const; void draw() const; // ... };状态模式将MENU,PLAYING,GAME_OVER等状态用不同的类来管理避免主循环中庞大的switch-case语句。这个用C和EasyX从零构建贪吃蛇的项目虽然代码量不大但它像一颗种子包含了游戏开发最基本的思想。从清晰的数据结构选择到稳定的游戏循环再到细致的碰撞检测和用户交互每一步的思考和实践都能带来扎实的收获。我提供的这份源码和讲解力求在简洁和完整之间找到平衡希望它能成为你探索更复杂编程世界的一块可靠跳板。在实际动手时最宝贵的往往不是一次写对而是遇到问题、分析问题、最终解决问题的那个过程。
返回列表