简介:DevC++贪吃蛇小游戏是适合C++初学者和期末课程设计的控制台游戏工程,将结构体、数组、随机数、键盘输入、碰撞检测等知识点串联成一个可玩项目。游戏源自1976年街机Blockade,本版用DevC++把经典玩法移植到文本控制台,逻辑简单直观。压缩包共9个文件,包含main.cpp源码、自定义头文件、DevC++工程配置、资源脚本以及编译好的exe,附带的Makefile和layout便于在DevC++中直接打开、重新编译;整个包仅112KB,结构紧凑,适合课堂演示与自学。项目已有11536人学习,热度较高,入门价值获得普遍认可。除基础游戏流程外,还实现了conio.h非阻塞键盘控制、多档难度速度、暂停计时、随机食物生成与碰撞结束判定,学习者可运行exe验证效果,也能逐行阅读源码并调整参数二次扩展,是理解C++基础语法和控制台交互设计的扎实素材。
1. 为什么是 DevC++ 跑贪吃蛇:图形库、事件循环和课设命中率
如果你第一次接触 C++ 编程,八成会听到“贪吃蛇”这个课设常客。用 DevC++ 做贪吃蛇,最大价值不在于游戏本身,而在于它把 C++ 的结构体、数组、随机数、键盘输入、循环和绘制压在一百行左右代码里,刚好构成一个完整的小工程闭环。不少人在某高校的期末课设、宿舍练手、简历 Demo 里都选过这个题目,但真正能跑起来的并不多。
问题主要出在两个地方:一是图形库没选对,控制台版贪吃蛇按一下键清一次屏,体验差且代码难以扩展;二是键盘输入和游戏循环的写法太随意,方向键按快了蛇就“自杀”。这篇笔记会把环境配置、数据结构和移动逻辑拆开讲透,连编译参数和常见翻车点一起给出来。适合拿它交作业的在校学生,也适合想把图形库小游戏套路摸清楚的初级开发者。
2. 先把图形库环境搭好:WinBGIm 配置与可复用的工程模板
2.1 图形库选型:为什么不建议用控制台清屏版
贪吃蛇这类实时游戏,渲染方式基本决定手感。控制台方案用system("cls")清屏再重绘,最简单,但闪烁明显,蛇的移动轨迹也很生硬,而且边框、食物、分数全靠printf坐标对齐,后期想加颜色和特效非常费劲。另一个常见方案是 EasyX,它专门给 Windows 下的 VC 系列配套,装到 DevC++ 里经常遇到库文件扫描不到、链接失败的问题,新手容易卡在一开始的环境阶段。
我一般会选 WinBGIm,它是一个开源的 BGI 兼容图形库,提供了graphics.h头文件和initwindow、rectangle、bar、setfillstyle等绘图函数。WinBGIm 和 DevC++ 的编译器配合成熟,只要把库文件放进 DevC++ 的 include 和 lib 目录,再在编译器选项里补上几个链接参数,就能直接编译运行。而且它保留了不少传统 BGI 风格函数,网上已有的贪吃蛇教程大部分都能直接参考。
从工程角度讲,WinBGIm 的窗口创建、延时刷新、键盘读取函数用得顺,后续做俄罗斯方块、迷宫寻路这类小游戏也能复用同一套环境。除了绘图需求,你还得准备一个明确的链接参数列表,这里容易漏,漏一个就报 undefined reference。下面是三种方案的对比。
| 方案 | 闪烁 | DevC++ 环境成本 | 可扩展性 | 适合场景 |
|---|---|---|---|---|
| 控制台清屏版 | 严重 | 零 | 弱 | 纯语法练手 |
| WinBGIm | 轻微 | 中等 | 较强 | 课设、小游戏 |
| EasyX | 轻微 | 较高 | 强 | VC 环境为主 |
选 WinBGIm 还有一层原因:它的头文件里自带kbhit、getch这类传统输入函数,和图形窗口同时使用不会有明显冲突。当然,我这里更推荐用GetAsyncKeyState读方向键,因为它的状态读取方式更适合“持续按住”的操作模型,后面会详细展开。
2.2 WinBGIm 的文件放法:四类文件分别落到哪
尝试之前先确认一下手头资源包的内容。一个可用的 WinBGIm 资源里至少要包含几类文件:graphics.h和winbgim.h头文件,以及libbgi.a静态库文件。有些压缩包还会带README和示例程序。
把graphics.h和winbgim.h复制到 DevC++ 安装目录下的 include 文件夹,把libbgi.a复制到 lib 文件夹。如果你用的 DevC++ 版本目录结构比较复杂,也以搜索到这两个目标目录为准。复制完成后,新开一个控制台程序,在代码里写上#include <graphics.h>,如果能顺利进入下一步,说明头文件已经被找到了。
这里要说一个容易误操作的细节:不要直接打开压缩包里的示例工程文件,因为它可能引用的是你的电脑不存在的路径。优先保证头文件和库文件就位,再自己建工程。我习惯的做法是先建一个空白文件,把下面的骨架代码粘进去,编译一次,过了才算环境真的没问题。
#include <graphics.h> #include <conio.h> int main() { initwindow(640, 480, "Snake Test"); setfillstyle(SOLID_FILL, GREEN); bar(100, 100, 120, 120); getch(); closegraph(); return 0; }initwindow的第一个参数是窗口宽度,第二个参数是高度,第三个是窗口标题。setfillstyle设置填充样式和颜色,bar画出指定左上角和右下角的实心矩形。这段代码能跑通,说明你的 WinBGIm 环境已经具备绘图能力。下一步再配置编译器链接参数。
2.3 编译参数与链接顺序:DevC++ 里的一次性配置
WinBGIm 的图形函数不只依赖它自己的静态库,还依赖 Windows 系统组件。编译时需要把下面几个库链接进去:bgi、gdi32、comdlg32、uuid、oleaut32、ole32。在 DevC++ 里打开“工具 > 编译器选项”,找到“在链接器命令行中加入以下命令”,把参数贴进去。
-lbgi -lgdi32 -lcomdlg32 -luuid -loleaut32 -lole32这里有个顺序和位置的坑:这些链接参数必须放在源文件之后,DevC++ 的全局链接设置会把它追加到编译命令行末尾,这是对的。如果你自己在命令行窗口手动编译,注意不要把-lbgi放在源文件之前,否则会出现函数定义了但链接器找不到的情况。配置完成之后,先把窗口清理一下,让 DevC++ 重新加载编译器配置,再编译刚才的骨架代码。
看到黑色控制台窗口和绿色的图形窗口同时弹出时,环境就算搞定了。后续所有代码都基于这一套环境,不用再动全局设置。
除了编译参数,源文件保存格式也值得提前确认。DevC++ 默认源文件用本地编码保存,Windows 下一般是 ANSI,也就是 GBK。如果代码里写了中文提示语,建议统一用 ANSI 保存,避免出现乱码。后面避坑章里我会再展开讲一个和编码相关的现象。
3. 核心实现:贪吃蛇的数据结构、移动、方向与碰撞判定
3.1 数据结构与全局变量:先把蛇表达出来
贪吃蛇的蛇身是一条连续运动的链,最容易理解的表达方式是数组。每个节点只存格子坐标,不存像素坐标,因为用格子坐标做碰撞检测、食物生成和墙壁判断都清晰得多。
我一般把游戏区域控制在 30×30 个格子,每个格子 20 像素,窗口大小就是 600×600。蛇的最大长度不会超过格子总数,所以蛇身数组直接用WIDTH * HEIGHT声明,不用动态分配。方向用两个整数dirX、dirY表示,1、0 代表向右,0、-1 代表向上。
#include <graphics.h> #include <time.h> #include <stdlib.h> #include <windows.h> #define CELL 20 #define WIDTH 30 #define HEIGHT 30 #define START_LEN 4 int snake[WIDTH * HEIGHT][2]; // 蛇身节点坐标,snake[0] 为蛇头 int length = START_LEN; // 当前蛇长 int dirX = 1, dirY = 0; // 当前方向,初始向右 int foodX, foodY; // 食物坐标 int score = 0; // 得分 int speed = 120; // 每帧延时,单位毫秒 int maxScore = 0; // 最高分snake数组第一维是节点编号,第二维存 x、y 坐标值。把蛇头放在snake[0],之后的节点依次排列,移动时整体后移一位,这种写法比链表简单,也不会引入指针和内存管理问题。START_LEN设为 4,这样开局蛇不会太短,也有足够长度演示尾部移动。
初始化时,把蛇放在区域中间偏左的位置,头和身体依次向右排开,食物生成在随机位置,同时清空得分和重置方向。注意随机数种子只初始化一次,否则每次运行食物位置看起来都有规律。
void initGame() { length = START_LEN; score = 0; speed = 120; dirX = 1; dirY = 0; for (int i = 0; i < length; i++) { snake[i][0] = 10 - i; snake[i][1] = 15; } srand((unsigned)time(NULL)); generateFood(); if (maxScore < 0) maxScore = 0; }初始化蛇头坐标是 (10, 15),身体依次是 (9, 15)、(8, 15)、(7, 15)。为什么不从 0 开始?因为开局要留给蛇向右移动的空间,而且后面食物生成会避开蛇身。这里的generateFood()是在随机位置生成食物,具体实现放在后面的小节。
3.2 移动算法拆解:头部前插、尾部是否保留
蛇的移动本质上是一个数组平移操作。每次移动,先把整条蛇身从尾部开始依次向前复制,腾出snake[0]的位置,再用方向向量更新蛇头坐标。如果有食物,则不能简单丢弃尾部坐标,要把旧尾补回到数组末端。
void moveSnake() { int tailX = snake[length - 1][0]; int tailY = snake[length - 1][1]; for (int i = length - 1; i > 0; i--) { snake[i][0] = snake[i - 1][0]; snake[i][1] = snake[i - 1][1]; } snake[0][0] += dirX; snake[0][1] += dirY; if (snake[0][0] == foodX && snake[0][1] == foodY) { snake[length][0] = tailX; snake[length][1] = tailY; length++; score += 10; } }先记录旧尾坐标,因为整体平移会覆盖掉最后一个节点的原始数据。从尾部往前复制,保证每个节点的数据都来自它前一个节点的旧位置,不会出现读取被覆盖的问题。更新蛇头后,判断是否吃到食物:如果吃到,把旧尾巴插回数组末尾,蛇长加一,分数加十。这个实现不涉及链表节点分配,但逻辑上等价于“头长一节、尾留一节”。
dirX和dirY每次只允许一个为 0,一个为 1 或 -1。不能出现两者同时非零或同时为零的情况。这一点在方向控制函数里必须限制住。同时要注意,反向检测不能把新方向直接改成当前方向的反方向,否则蛇会一头扎进自己的身体里。
3.3 食物生成与碰撞判定:避免食物掉进身体
食物生成是贪吃蛇里最容易写出隐蔽 bug 的地方。随机数范围必须限定在格子索引 0 到 WIDTH-1、0 到 HEIGHT-1 之间,不能把窗口边缘的边框像素当成可放食物的位置。生成后还要检查这个坐标是否被蛇身占用,如果占用就重新生成。
void generateFood() { bool onSnake; do { foodX = rand() % WIDTH; foodY = rand() % HEIGHT; onSnake = false; for (int i = 0; i < length; i++) { if (snake[i][0] == foodX && snake[i][1] == foodY) { onSnake = true; break; } } } while (onSnake); }do...while至少执行一次,保证必定生成一个坐标。食物不能放在蛇身上是所有版本的通用约束,如果不做检查,食物会“藏”在蛇身体里,玩家看似一直吃不到,分数却可能触发变长逻辑。这里的检查复杂度是 O(n),蛇最多 900 节,每帧生成一次完全够用。
碰撞判定分两类:撞墙和撞自身。撞墙直接看蛇头坐标是否越过边界,撞自身则从身体第一节检查到最后一节,只要有一节坐标和蛇头重合就算失败。注意,碰撞判定要在移动之后、绘制之前进行,不然画面会把死后的蛇画出来。
bool isDead() { if (snake[0][0] < 0 || snake[0][0] >= WIDTH || snake[0][1] < 0 || snake[0][1] >= HEIGHT) { return true; } for (int i = 1; i < length; i++) { if (snake[i][0] == snake[0][0] && snake[i][1] == snake[0][1]) { return true; } } return false; }自我碰撞的遍历从i = 1开始,因为snake[0]是蛇头,不需要和自己比。检查必须在length增加之后执行吗?不需要,因为蛇头更新后和食物的判断已经完成,如果吃到了食物,length被更新,旧尾补在末尾,仍然要检查新蛇头是否碰到身体的任何部分。有些实现把吃食物放在碰撞判定后面,逻辑就反了,蛇头会先判定撞到食物导致失败,这个问题要小心。
3.4 主循环与渲染:窗口刷新、延时和输入处理
游戏主循环用while (1)包裹,渲染先用cleardevice()清空整幅画布,再按顺序画墙体、蛇身、食物和分数信息。画蛇身时把每个格子的坐标换算成像素坐标,用setfillstyle加bar画实心格子,蛇头单独用不同颜色区分。
void draw() { cleardevice(); setcolor(WHITE); rectangle(0, 0, WIDTH * CELL, HEIGHT * CELL); for (int i = 0; i < length; i++) { int x1 = snake[i][0] * CELL; int y1 = snake[i][1] * CELL; int x2 = x1 + CELL - 1; int y2 = y1 + CELL - 1; if (i == 0) { setfillstyle(SOLID_FILL, YELLOW); } else { setfillstyle(SOLID_FILL, GREEN); } bar(x1, y1, x2, y2); } setfillstyle(SOLID_FILL, RED); bar(foodX * CELL, foodY * CELL, foodX * CELL + CELL - 1, foodY * CELL + CELL - 1); char info[64]; sprintf(info, "Score: %d Max: %d", score, maxScore); outtextxy(10, HEIGHT * CELL + 10, info); }bar的第三个和第四个参数是右下角坐标,这里减 1 是为了避免和相邻格子重叠出视觉粘连。分数文本放在窗口下方,outtextxy的坐标是像素位置,不能直接写格子坐标。如果你的窗口高度只画了 600 像素的格子区域,要给文本留出额外空间,所以initwindow的高度建议设置成HEIGHT * CELL + 40。
输入处理用GetAsyncKeyState读取四个方向键。每帧只读取一次,并且用新的方向变量暂存,然后判断新方向是否和当前方向相反,允许相反就不更新,避免蛇瞬间掉头撞死。
void handleInput() { int newDirX = dirX; int newDirY = dirY; if (GetAsyncKeyState(VK_UP) & 0x8000) { newDirX = 0; newDirY = -1; } else if (GetAsyncKeyState(VK_DOWN) & 0x8000) { newDirX = 0; newDirY = 1; } else if (GetAsyncKeyState(VK_LEFT) & 0x8000) { newDirX = -1; newDirY = 0; } else if (GetAsyncKeyState(VK_RIGHT) & 0x8000) { newDirX = 1; newDirY = 0; } if (!(newDirX == -dirX && newDirY == -dirY)) { dirX = newDirX; dirY = newDirY; } }主循环的每帧顺序是:处理输入、移动、判断死亡、绘制、延时。死亡后先显示分数,再等待玩家按键退出或重开。Sleep(speed)是 Windows API 提供的毫秒级延时,放在绘制之后能让画面停留一段时间,speed越小蛇跑得越快。
int main() { initwindow(WIDTH * CELL, HEIGHT * CELL + 40, "Snake"); loadHighScore(); initGame(); while (1) { handleInput(); moveSnake(); if (isDead()) { break; } if (score > maxScore) { maxScore = score; } draw(); Sleep(speed); } saveHighScore(); closegraph(); return 0; }loadHighScore和saveHighScore是最高分存档函数,第 5 章会给出完整实现。主循环里没有额外的事件处理函数,WinBGIm 的窗口消息循环已经被Sleep和绘制函数交替占用,实际体验中足够稳定。
4. 避坑记录:DevC++ 跑贪吃蛇的五个常见翻车点
4.1 编译期与窗口期:四类最影响进度的报错
现象一:graphics.h: No such file or directory,头文件找不到。原因很直白,graphics.h没有被复制到 DevC++ 的 include 目录,或者复制到了 MinGW 自带 include 下的子目录里。解决方法是搜索 DevC++ 安装目录下已有的stdio.h所在位置,把两个头文件放进同一个 include 根目录。重新打开工程后再编译,一般能解决。
现象二:代码能写,但链接时报undefined reference to initwindow。原因通常是链接参数没有配置,或者libbgi.a没有放到 lib 目录。这里的识别关键是“引用未定义”而不是“找不到头文件”,说明编译阶段已经通过,问题出在链接器没有找到图形库实现。解决方式是回到第 2.3 节检查链接参数是否完整,确认-lbgi后面没有漏掉-lgdi32。漏掉-lgdi32时会出现大量undefined reference to报错,而且报错点集中在绘制函数附近。
现象三:运行后窗口只闪一下就消失。原因很典型,程序的主函数里只调用了initwindow,没有等待按键或循环就执行到closegraph,图形窗口生命周期过短。解决方法是把骨架代码里的getch()加回来,或者直接把贪吃蛇主循环放进去。记住,initwindow之后必须有阻塞操作,否则一切绘制都看不到。
现象四:中文乱码,outtextxy显示的分数标题变成乱码。原因多数是源文件保存编码和 Windows 控制台/图形窗口的 ANSI 编码不一致。DevC++ 默认按本地代码页保存,如果从网页或者模板复制过来的代码是 UTF-8 编码,字符就会被错误解释。解决方法是把源文件用 DevC++ 的“另存为”功能重新保存为 ANSI 编码,保存之后重新编译。如果你在代码里使用了char数组存中文,还要注意sprintf拼接时别把中文字符串截断成半个字符。
4.2 运行期与逻辑期:方向、闪烁、随机食物三个细节
现象五:蛇移动中出现突然反向,然后撞到自己。原因出在方向键处理逻辑:如果某一帧按了左键,下一帧又按了右键,两个按键事件都读取成功,方向就变成了当前方向的相反方向,蛇头直接钻向身体。这个问题的本质是“同一帧内多次改方向”和“相邻帧之间方向反转”没有防护。解决方法是先暂存新方向,再和当前方向做一次反向判断,确认不是 180 度反转才更新。按下方向键后不应当连续修改方向,尤其是蛇在高速移动时,连按两下方向键非常容易酿成自杀。
另外还有一个和逻辑相关的细节:移动、碰撞、绘制、延时的顺序不能互换。如果先绘制再移动,你会看到蛇的前端滞后一格;如果先碰撞再移动,吃食物判断会慢半拍,分数和蛇长会延迟一帧同步。我在初版代码里先移动再碰撞,碰撞检测用的是新蛇头位置,这个顺序稳定可靠。
闪烁问题也要提一下。WinBGIm 默认没有完整双缓冲,连续绘制时画面会轻微闪烁,尤其在蛇速加快之后比较明显。解决方式有两条路:一是降低帧率,把speed控制在 60 毫秒以上,视觉上可以接受;二是利用setactivepage和setvisualpage做双缓冲,但这两条函数在不同 WinBGIm 版本里的行为不完全一致,属于玄学范畴。普通课设不需要双缓冲,重点保证语义正确即可。
食物生成还有一个隐性坑:随机数种子的初始化位置。如果srand放在generateFood内部,并且generateFood被反复调用,每次运行游戏都可能产生完全相同的食物序列。正确做法是在initGame里只调用一次srand((unsigned)time(NULL)),后续随机数序列由运行时间驱动,食物分布才足够随机。
5. 做完基础版之后:暂停菜单、难度曲线和最高分存档
基础版能跑通后,离课设的“加分项”只差三个功能:暂停、加速、最高分持久化。这三块不需要改结构,往主循环里插代码就行,但每块都有自己的细节。
暂停功能用 ESC 键切换。给游戏增加一个gameState标志,0 运行、1 暂停。按下 ESC 后反转状态,并在暂停状态下走Sleep(100)防止按键抖动。注意这个逻辑和方向控制不同,它需要“按下一次、切换一次”,不能持续按住就来回切换,所以我在记忆里保存上一帧的 ESC 状态,只在状态由未按到按下时触发切换。
int gameState = 0; int escLast = 0; // 在 handleInput 末尾追加 int escNow = (GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0; if (escNow && !escLast) { gameState = !gameState; } escLast = escNow;gameState == 1时,移动和碰撞逻辑都跳过,只保留绘制和延时。这样玩家松手后,蛇停在当前画面,不会继续移动。
加速曲线要克制。最直观的写法是每次吃到食物speed -= 5,但到了蛇长 30 左右,视觉帧率会快得失控。我一般把速度下限设为 60 毫秒,低于这个值不再继续加速,否则玩家刚反应过来,蛇已经撞墙了。在moveSnake成功吃到食物后追加一行:if (speed > 60) speed -= 2;。减 2 比减 5 更平滑,玩家体验是从轻松到紧张,而不是突然飙车。
最高分持久化是最容易拿分的扩展,用一个二进制文件存整数,启动时读取,退出时写入。注意读取失败时要把分数初始化为 0,不能沿用上一次内存里的残留值。这个函数放在initGame之前调用,确保初始分数显示正确。
void loadHighScore() { FILE* fp = fopen("snake_score.dat", "r"); if (fp == NULL) { maxScore = 0; return; } fscanf(fp, "%d", &maxScore); fclose(fp); } void saveHighScore() { FILE* fp = fopen("snake_score.dat", "w"); if (fp == NULL) { return; } fprintf(fp, "%d", maxScore); fclose(fp); }文件名叫snake_score.dat,放在可执行文件同目录。如果你在 DevC++ 里点“运行”,工作目录一般是源文件所在目录,文件会生成在那里。提交作业时记得把这个 dat 文件一起抄进去,不然换台电脑得重新刷分。
把这三个功能合起来,一个完整的 DevC++ 贪吃蛇工程就算封版了。我后来再写类似小游戏时,都会强制先过一遍环境配置清单:头文件路径、链接参数、源文件编码、方向反转保护和碰撞顺序,这五处只要有一处没检查,后面跑起来大概率要返工。尤其是链接参数,每次重新解压 DevC++ 或者换机器编译,我都会把-lbgi -lgdi32 -lcomdlg32 -luuid -loleaut32 -lole32整行重新核对一遍,这个习惯帮我少踩了无数坑。希望帮到你。
本文还有配套的精品资源,点击获取