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::vector<std::pair<int, int>>来存储蛇身。pair<int, 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)这样的判断。
计算新蛇头位置与更新蛇身: 假设蛇身存储在vector<pair<int, int>> snake中,snake.back()是当前蛇头。
// 获取当前蛇头坐标 pair<int, int> head = snake.back(); pair<int, 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::vector<std::pair<int, int>> snake; std::pair<int, 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::vector<Point> 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从零构建贪吃蛇的项目,虽然代码量不大,但它像一颗种子,包含了游戏开发最基本的思想。从清晰的数据结构选择,到稳定的游戏循环,再到细致的碰撞检测和用户交互,每一步的思考和实践都能带来扎实的收获。我提供的这份源码和讲解,力求在简洁和完整之间找到平衡,希望它能成为你探索更复杂编程世界的一块可靠跳板。在实际动手时,最宝贵的往往不是一次写对,而是遇到问题、分析问题、最终解决问题的那个过程。
