资讯动态

Dev-C++贪吃蛇:C++控制台游戏主循环与碰撞检测实战

发布时间:2026/10/28 2:17:28 来源:尧图企业网站定制
简介DevC贪吃蛇小游戏是一份面向C入门者、课程设计学生与控制台游戏学习者的完整项目案例借助Dev-C工程演示经典贪吃蛇从输入控制到游戏结束的完整流程。压缩包共9个文件、约112KB以cpp主程序、头文件、Dev-C工程配置文件和可执行文件为主导入IDE后即可查看源码、编译运行并对照调试。目前已有11544人学习下载。项目内容涵盖蛇身与食物位置维护、方向键实时控制、难度调速、计时暂停、碰撞检测、得分与重置等环节也涉及非阻塞输入、清屏、随机食物生成以及控制台游戏循环的基本组织。读者可据此理解数组、循环、条件判断和屏幕刷新在小型游戏中的组织方式适合作为C课程实践、期末小项目或控制台游戏开发入门参考也能为后续扩展功能与排错提供清晰蓝本。1. 用 Dev-C 写贪吃蛇这个“老掉牙”的组合为什么还值得下载期末前一周一个学弟抱着装了 Dev-C 的笔记本找我说要交 C 课程设计题目就是贪吃蛇。程序能跑但一按方向键就撞死自己他改了两天没找到原因。我看十分钟就发现问题他把方向键读到的值直接写进了当前方向变量。这类毛病在控制台版贪吃蛇里特别集中——代码不到三百行坑全堆在输入、碰撞和刷新这几个点上。这份 DevC 贪吃蛇小游戏资源路子就是“能编译、能通关、每一行都讲得清”纯 C 加 Windows 控制台 API不依赖图形库Dev-C 打开.dev工程按 F11 就能出画面。适合刚学完数组、循环、结构体要交课程设计的人也适合手上缺一个干净小项目想把“输入—更新—渲染”这条主线一次走通的从业者。下面按拆包、跑通、拆函数、避坑、调手感的顺序讲。2. 拆包四个模块与蛇身数据结构的选型理由2.1 一份能编译的 .dev 工程该包含哪些文件拿到压缩包先别急着双击 exe。控制台小游戏的工程结构通常很固定先确认下面这几类文件在不在缺哪一类就决定了你要“补”还是“重写”文件类型典型文件名作用缺失后果工程文件贪吃蛇.devDev-C 的工程描述记录源文件列表和编译参数只能手动新建工程再逐个添加源文件主源文件main.cpp入口、主循环、初始化无法编译头文件snake.h/game.h常量、结构体、函数声明单文件版可以没有多文件版必须有实现文件game.cpp移动、碰撞、绘制等函数体多文件版缺它会在链接期报 undefined reference编译中间件*.o、Makefile.win增量编译产物建议直接删掉重新编译见 5.1单文件版和多文件版没有优劣只看你的交付场景课程设计要交报告、要讲模块划分多文件更像样自己练手、想快速改参数单文件改起来更省事。我一般会先把*.o和旧 exe 全删掉再在 Dev-C 里执行一次“全部重新编译”避免旧目标文件混进来导致“明明改了代码却没生效”这种血泪问题。打开工程后确认两件事工具 → 编译选项里编译器套件是不是 Windows 目标TDM-GCC 一类以及“编译时加入以下命令”里有没有-stdc11。资源里如果用了nullptr、auto、范围 for缺这个参数会直接报nullptr was not declared in this scope很多人以为是代码坏了。2.2 四个模块的职责边界控制台贪吃蛇看着简单本质还是一个游戏循环模块切不干净后面加个加速功能都能把碰撞判定带崩。合格的划分是四块输入模块非阻塞读键只负责把按键翻译成“期望方向”不修改任何游戏状态。更新模块按当前方向推进一格做碰撞判定处理吃食物和增长改的是数据结构。渲染模块把状态画到屏幕只读状态绝不产生随机数、绝不改蛇身。节奏模块控制一帧走多快也就是帧间隔和步进累加。为什么非要这么切因为控制台版最容易出的 bug是“渲染时顺手改了状态”。比如有人在画蛇的函数里调rand()生成食物结果每帧都重新随机一次食物在屏幕上乱跳还有人在碰撞判定里顺手把蛇头推进去了撞墙之后蛇头停在墙里下一帧再从墙外冒出来看起来像穿墙。把“改状态”和“画状态”分开这些玄学问题会少一大半。2.3 蛇身用数组还是链表先看你要不要“增长”这是选型上唯一值得犹豫的地方。蛇的形态是“头进尾出”天然就是一个双端队列#include deque std::dequePoint snake; // front 是蛇头back 是蛇尾 // 每走一步 snake.push_front(nextHead); // 头进O(1) if (!willGrow) snake.pop_back(); // 没吃到食物才尾出O(1)deque的好处是长度自适应不用预先估一个上限push_front和pop_back都是常数时间控制台版每帧也就操作几十次性能开销肉眼不可见。缺点是内存不连续如果你想做“回放”“存档”“把整条蛇序列化写文件”还得遍历拷贝一遍。数组方案适合固定上限的场景写法是环形缓冲const int MAX_LEN MAP_W * MAP_H; // 最极端情况蛇占满整张图 Point body[MAX_LEN]; int headIdx 0; // body[headIdx] 是蛇头 int tailIdx 0; // body[tailIdx] 是蛇尾 int len 3; inline Point seg(int i) { return body[(tailIdx i) % MAX_LEN]; } // ilen-1 即蛇头环形数组的好处是零动态分配、内存连续存盘时一个fwrite(body, sizeof(Point), len, fp)就完事坏处是索引写错就是越界而且MAX_LEN必须和地图尺寸严格对齐改了地图忘了改上限蛇一长就踩内存。我的建议是练习和课程设计用deque要做回放、要交“数据结构课程”的作业再用环形数组那是另一个评分点。不管选哪种蛇身只存坐标不要存字符。有人在结构体里加一个char ch表示这一节画什么结果换皮肤的时候整片代码都要改。2.4 坐标系、方向枚举与边界常量一次定死坐标约定不统一是后期最烦的坑。控制台光标坐标是(列, 行)左上角(0,0)很多人写游戏逻辑时用(x, y)画的时候忘了换算蛇就整体偏移一格。常见做法是把地图四周留出墙壁可活动区域从1开始#pragma once const int MAP_W 24; // 可活动区域宽度不含左右墙 const int MAP_H 18; // 可活动区域高度不含上下墙 // 蛇头坐标合法范围x ∈ [1, MAP_W]y ∈ [1, MAP_H] // 墙画在 x0、xMAP_W1、y0、yMAP_H1 上 enum Dir { DIR_UP 0, DIR_DOWN, DIR_LEFT, DIR_RIGHT }; struct Point { int x, y; }; // 方向增量表用查表代替 switch少写四行还不会漏分支 const int DX[4] { 0, 0, -1, 1 }; const int DY[4] { -1, 1, 0, 0 };这样定死之后撞墙判定就退化成两次比较不用去扫地图数组方向反向了也有现成办法inline bool isOpposite(Dir a, Dir b) { return (a DIR_UP b DIR_DOWN) || (a DIR_DOWN b DIR_UP) || (a DIR_LEFT b DIR_RIGHT) || (a DIR_RIGHT b DIR_LEFT); }提示MAP_W建议取偶数。蛇身如果用[]、##这类双列字符绘制一列字符对应两列光标宽度取偶数时墙和蛇身才能对齐否则右边框会参差不齐。3. 在 Dev-C 里跑通第一版编码、主循环与吃食物3.1 新建工程与文件编码先把乱码掐死在源头Dev-C 的默认编码在不同版本里不一致源文件存 UTF-8 而控制台按 GBK代码页 936解释菜单里的中文就会变成一堆问号或方块。两种做法选一种坚持到底方案一省事源文件统一保存为 ANSI/GBK控制台默认代码页就是 936直接printf(贪吃蛇)正常显示。方案二通用源文件存 UTF-8程序启动第一行加SetConsoleOutputCP(65001);把控制台输出代码页切成 UTF-8。方案二有个副作用切成 65001 之后某些老版本控制台对_getch()的中文输入回显不正常但贪吃蛇只读方向键不受影响。我一般用方案二因为源文件进 Git 不会因为编码差异产生大段 diff。#include windows.h #include cstdio int main() { SetConsoleOutputCP(65001); // 控制台按 UTF-8 解释输出 SetConsoleTitleA(Snake - DevC); // 标题栏用 A 版 API避免宽字符问题 printf(按方向键开始ESC 退出\n); // ... return 0; }这里有个容易忽略的点SetConsoleTitle在UNICODE宏开启时会展开成宽字符版本而 Dev-C 默认工程不加-DUNICODE为了两边都能编过显式写SetConsoleTitleA。同类问题还有MessageBox课程设计里少用为妙。新建工程时选“Console Application”语言选 C把main.cpp和头文件加进工程。别用“新建源文件”然后单独编译那样工程里没有文件列表后面加第二个.cpp会链接失败。3.2 主循环骨架输入、更新、渲染三段式所有控制台小游戏的主循环都是同一个骨架差别只在“更新”里干什么int main() { srand((unsigned)time(NULL)); // 播种只做一次放在循环外面 hideCursor(); // 隐藏光标见 4.1 GameState gs; initGame(gs); drawBorder(); render(gs); int frameDelay 150; // 帧间隔毫秒 bool running true; while (running) { Dir want; if (readInput(want)) { // 非阻塞没按键就返回 false if (want DIR_NONE) running false; // ESC else applyDir(gs, want); } if (!step(gs)) { // 走一步返回 false 表示撞了 running false; break; } render(gs); // 只重绘变化的格子见 5.3 Sleep(frameDelay); // 放循环末尾控制节奏 } showResult(gs); return 0; }几个参数值得说清楚。frameDelay是一次循环的“睡眠时间”不是精确帧率因为step、render本身也要花时间实际帧间隔是Sleep 时长 逻辑耗时这也是为什么第 6 章要换成固定步长累加。srand必须放在循环外放里面且时间种子精度只有秒级同一秒内重启两次地图布局完全一样测随机性的时候会以为随机函数坏了。applyDir和step分开是有意为之按键只是登记“想往哪走”真正转向发生在下一次step里。这个设计直接消灭了“一帧内连按导致反向自撞”的问题具体见 4.4。3.3 食物生成随机不能落在蛇身上最朴素的写法是rand() % MAP_W 1问题有两个可能生成在蛇身上以及蛇快占满地图时死循环。Point spawnFood(const std::dequePoint snake) { Point f; int guard 0; do { f.x rand() % MAP_W 1; f.y rand() % MAP_H 1; guard; } while (onSnake(snake, f) guard 2000); // 后悔药防死循环 return f; } bool onSnake(const std::dequePoint s, Point p) { for (size_t i 0; i s.size(); i) if (s[i].x p.x s[i].y p.y) return true; return false; }guard计数器是很多教程版本没有的。当蛇长到接近满图时随机命中的空白格概率极低do-while会卡在这几行里表现出来就是“游戏突然卡死”。加个上限超过就退化成顺序扫描找第一个空格逻辑上更稳。另一个细节rand() % n有轻微的取模偏差低编号的格子被选中的概率高一点点。控制台版完全不用管但如果你要写“食物出现概率”的统计测试记得用(int)((double)rand() / RAND_MAX * n)。3.4 计分、加速与结束判定吃到食物的处理集中在onEat里别散落在各处struct GameState { std::dequePoint snake; Point food; Dir dir; int score; int eaten; // 已吃数量和分数分开记方便调参 bool grow; // 本帧是否要长一节 }; void onEat(GameState gs, int frameDelay) { gs.score 10; gs.eaten 1; gs.grow true; if (gs.eaten % 5 0 frameDelay 60) frameDelay - 10; // 每 5 个食物提速 10ms下限 60ms gs.food spawnFood(gs.snake); }frameDelay的下限必须有。不加下限玩到三四十个食物之后Sleep(0)甚至负数Sleep传负数会被当成一个极大值实际是按INFINITE处理表现就是“吃得越多游戏越卡”非常反直觉。加速的粒度和下限按手感调150ms 起步、60ms 封顶是我试下来比较舒服的一组。结束判定要有区分度撞墙、撞自己、按 ESC 退出三种情况的结算文案不一样玩家才知道自己死在哪。课程设计答辩时这点很加分因为它说明你想过状态机。4. 关键函数拆解绘制、移动与碰撞判定顺序4.1 gotoxy 定位绘制与隐藏光标控制台没有“画布”只有光标位置。想让蛇动起来且不闪屏必须能精确地把光标挪到某一格重画而不是每帧system(cls)全清。#include windows.h void gotoxy(int col, int row) { COORD pos { (SHORT)col, (SHORT)row }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); } void hideCursor() { HANDLE h GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO info; GetConsoleCursorInfo(h, info); info.bVisible FALSE; // 光标乱闪很难看先关掉 SetConsoleCursorInfo(h, info); }COORD里的 X 是列、Y 是行和数学坐标系的 y 轴方向相反这一点在“蛇向上走”时最容易写反向上是y - 1不是y 1。隐藏光标不纯粹是美观问题。光标停在屏幕上会跟着gotoxy一起跳如果正好停在蛇头那一格看起来像蛇头多了一个方块截图贴进报告里非常显眼。注意SetConsoleCursorPosition的坐标原点包含控制台窗口的缓冲区原点。如果你的控制台窗口设置了“快速编辑模式”鼠标点一下就会暂停输出游戏看起来像卡死。跑之前右键标题栏 → 属性 → 取消勾选“快速编辑模式”这个坑我踩过不止一次。4.2 头进尾出的移动实现移动是整个游戏里唯一修改蛇身的地方写法直接决定碰撞判定对不对bool step(GameState gs) { Point next gs.snake.front(); next.x DX[gs.dir]; next.y DY[gs.dir]; // 1) 撞墙坐标越界 if (next.x 1 || next.x MAP_W || next.y 1 || next.y MAP_H) return false; // 2) 撞自己先算这帧会不会长决定尾巴那格算不算“安全通道” bool willGrow (next.x gs.food.x next.y gs.food.y); size_t checkLen willGrow ? gs.snake.size() : gs.snake.size() - 1; for (size_t i 0; i checkLen; i) { if (gs.snake[i].x next.x gs.snake[i].y next.y) return false; } // 3) 全部通过才真正改数据 gs.snake.push_front(next); if (willGrow) onEat(gs, gs.frameDelay); else gs.snake.pop_back(); return true; }第 2 步的checkLen是核心。蛇每走一步尾巴会离开一格所以“头撞到尾巴当前所在的格子”实际上是安全的——下一帧尾巴就不在那了。如果循环写满snake.size()会出现“蛇紧贴着自己尾巴拐弯就死”的诡异现象而且只在蛇比较长的时候偶发非常难查。willGrow也影响这一步吃到食物这一帧尾巴不动所以尾巴那一格不能当成安全通道必须完整检查。4.3 三种碰撞的判定顺序不能乱顺序错了会出现“明明撞墙了却先判自撞”“死亡位置比实际晚一格”这类问题。正确顺序是边界 → 自身 → 食物原因是边界判定不需要遍历先做能省一次循环自身判定要在真正插入蛇头之前做食物判定要放在自身判定之后因为它决定checkLen。判定时机依赖常见写错方式撞墙计算完新头坐标立即做MAP_W、MAP_H写成 MAP_W 1蛇头会先穿进墙里一格撞自身插入蛇头之前蛇身队列、willGrow循环写满全长度贴着尾巴转弯被误判吃食物自身判定通过、插入之前食物坐标放在插入之后导致蛇头已经算进蛇身再比对永远撞不到还有个隐藏顺序问题如果先push_front再判撞撞墙的那次也会把蛇头推进队列死亡动画里蛇头会突出在墙外。视觉上的“穿墙”多半就是这么来的不是渲染的锅。4.4 方向键读取与转向缓冲控制台下方向键返回两个字节前缀0或224再加一个扫描码。只读一次会拿到前缀扫描码留在缓冲区里下一次读又读到它于是“按一下方向键蛇连转两次”。#include conio.h // 返回 false 表示没按键want 为 DIR_NONE 表示 ESC bool readInput(Dir want) { if (!_kbhit()) return false; // 非阻塞没按键立即返回 int k _getch(); if (k 0 || k 224) { // 双字节前缀 int code _getch(); // 第二次读才是真正的方向键码 switch (code) { case 72: want DIR_UP; break; case 80: want DIR_DOWN; break; case 75: want DIR_LEFT; break; case 77: want DIR_RIGHT; break; default: return false; } return true; } if (k 27) { want DIR_NONE; return true; } // ESC return false; }关键的转向缓冲这样写void applyDir(GameState gs, Dir want) { // 只和“已生效的方向”比较不能和“本帧刚登记的方向”比 if (want gs.dir || isOpposite(want, gs.dir)) return; gs.dir want; }为什么不能直接gs.dir want玩家手快的时候在一帧之内可能连按“上”再按“左”。当前方向是“右”第一次比较“左”和“右”是反向被拒绝没问题但如果第一次按的是“上”gs.dir立刻变成“上”第二次按“下”时比较的是“上”和“下”同样会被拒——看起来没问题可一旦配上“速度越快按键越容易堆积”就会出现比较基准被污染的情况两条方向在缓冲里来回覆盖最后蛇往奇怪的方向拐玩家叫你玄学。把输入缓冲成pendingDir、在step里统一生效是最省心的做法。_kbhit和_getch在 MinGW 的conio.h里带下划线前缀如果你的编译器只声明了带下划线的版本直接写kbhit()会报未定义见 5.1。5. 避坑与排查Dev-C 下最容易翻车的五个点5.1 现象编译报conio.h: No such file or directory或_kbhit未定义现象代码在别人机器上能编在你的 Dev-C 里报找不到头文件或者[Error] _kbhit was not declared in this scope。原因三种可能。一是工程指向的编译器套件不是 Windows 目标比如误配了某个不带 console 支持的套件conio.h是 Windows 控制台扩展而不是 C 标准头非 Windows 目标下本来就没有二是头文件里只暴露了带下划线的版本而代码里写的是老式kbhit()三是把conio.h和windows.h的包含顺序写反某些老版本头文件里宏冲突把声明吃掉了。解决先看工具 → 编译选项 → 编译器设定确认选的是 Windows 目标套件然后把调用统一成_kbhit()、_getch()或者加一行#define kbhit _kbhit做兼容包含顺序固定为#include windows.h在前、#include conio.h在后。如果还不行把conio.h换成#include windows.h之后直接用GetAsyncKeyState查键牺牲一点可读性换编译通过。5.2 现象中文显示成乱码、问号或方块现象菜单和结算文案里的中文全变成锟斤拷或者一排?但英文和数字正常。原因源文件编码和控制台输出代码页不一致。Dev-C 默认保存编码在不同版本里不统一源文件存成 UTF-8 而控制台按 936 解释双字节被拆开重新组合就是乱码反过来存 GBK 但程序里调了SetConsoleOutputCP(65001)同样会乱。解决二选一并坚持到底。要么源文件存 ANSI/GBK、程序里不碰代码页要么源文件存 UTF-8、main第一行调SetConsoleOutputCP(65001)。改完编码后必须“全部重新编译”只按 F9 编译单文件有时不会刷新。另外别用system(chcp 65001)代替 API它会额外弹一行提示还会把原来的代码页改掉。5.3 现象蛇一动整个屏幕就闪或者留下残影现象蛇每走一步屏幕明显抖一下或者蛇走过去之后原来的位置没擦干净拖出一条越来越长的影子。原因每帧system(cls)全屏清空再全画控制台的清屏是整屏重绘闪是必然的残影则是没有擦除尾巴离开的那一格只画了新头。解决改成局部刷新只擦尾巴、画新头边界和食物画一次就不再动。void renderStep(const GameState gs, Point oldTail) { // 宽度 2 列因为蛇身用双列字符绘制 gotoxy(oldTail.x * 2 - 1, oldTail.y); printf( ); // 擦尾 gotoxy(gs.snake.front().x * 2 - 1, gs.snake.front().y); printf([]); // 画头 }坐标乘 2 是因为一个汉字或[]占两列光标不乘的话蛇身宽度和墙壁对不上越走越偏。擦除用两个空格而不是空字符串空格能覆盖掉原字符空字符串什么都不做。5.4 现象按方向键没反应或者一按就撞死自己现象程序跑起来后方向键完全无效只能用 WASD或者方向键能用但快速连按两次就会自撞。原因没反应多半是只读了一次_getch()拿到的是前缀字节扫描码留在缓冲区里后面的读取全部错位自撞则是在输入处理里直接修改了当前方向导致反向判定失效见 4.4。还有一种情况在 Dev-C 内部按 F11 运行时控制台窗口没有获得焦点按键被 IDE 截走了。解决读键必须成对读前缀判断写if (k 0 || k 224)方向先用pendingDir缓存在step里生效测试时编译完直接双击 exe 跑别在 IDE 里测按键手感。另外Sleep时间太长时按键会在缓冲区堆积可以用while (_kbhit())把缓冲读空、只保留最后一次有效转向。5.5 现象分数高了以后蛇穿墙、瞬移或者直接崩溃现象吃了十几个食物之后蛇从右边出去又从左边冒出来或者吃到某个位置程序直接闪退没有任何报错。原因两处。一是撞墙判定写成了next.x MAP_W 1多放了一格蛇头进了墙里还没判死二是用环形数组存蛇身时索引算错MAX_LEN和地图尺寸不同步蛇一长就越界写内存表现为随机崩溃。加速逻辑也可能是一帧走多步、碰撞只判一次看起来就像瞬移。解决把边界判断抽成一个函数inMap(Point)所有地方调用同一份不要复制粘贴用环形数组时在seg()里加assert(i 0 i len)Debug 版本跑一遍就能定位加速只调Sleep的时长不要在一帧里循环调用多次step需要一帧多步就用第 6 章的固定步长累加每步都完整做一次碰撞判定。6. 进阶把手感和可维护性一起调出来6.1 固定步长累加速度不再靠猜 SleepSleep(frameDelay)的问题是帧间隔不精确渲染耗时、系统调度都会叠加进去加速之后体感变化不均匀。常见做法是把“逻辑步进”和“渲染”解耦用时间累加器驱动int stepMs 120; // 逻辑步长 int acc 0; DWORD last GetTickCount(); while (running) { DWORD now GetTickCount(); acc (int)(now - last); // 32 位计数 49 天回绕游戏时长内无影响 last now; Dir want; if (readInput(want)) { /* 处理按键 */ } while (acc stepMs) { // 该走几步就走几步每步都判碰撞 acc - stepMs; if (!step(gs)) { running false; break; } } render(gs); Sleep(1); // 让出 CPU别空转跑满一个核 }这样调速度只改stepMs改到 60 也不会出现“一帧走两步却没判撞”的漏洞因为每一小步都过了完整的step。Sleep(1)别省省了就是 100% 占用一个核笔记本风扇会告诉你。6.2 逻辑与渲染分层以后换图形界面不用重写想从控制台迁到图形界面最痛的不是画图是发现逻辑和printf搅在一起。提前把状态独立出来struct GameState { // 只放数据不放任何 IO std::dequePoint snake; Point food; Dir dir; int score; bool alive; }; void update(GameState s, Dir input, int stepMs); // 不碰 printf / gotoxy void render(const GameState s); // 不碰 rand / 不改数据分层之后render换成 GDI 的Rectangle、或者换成任何一个绘图库逻辑层一行不用动。附带一个好处输入可以录制。把每帧的Dir存成数组配一个固定种子就能完整复现“吃到第 37 个食物时撞死”的过程比盯着屏幕猜快得多。6.3 五分钟自测表改完代码别急着交按这张表跑一遍能挡掉八成返工测试项操作期望结果边界贴着四面墙各走一次撞墙立即结束蛇头不越出边框贴尾转弯蛇长 ≥ 5 后贴着尾巴拐弯不判死正常通过反向键向右走时按左无反应蛇继续向右连按转向一帧内连按上、左只生效一次不自撞食物重叠吃到只剩 2 格空白不卡死食物落在空白格加速下限连吃 40 个食物帧间隔不低于下限不出现负数 Sleep退出ESC、撞墙、撞自己各一次三种结算文案正确程序正常退出最后说个自己的习惯。刚学那会儿我总觉得这种两百多行的小游戏“跑起来就行”结果每次交作业前改个加速功能都要重新调半天碰撞。后来我强制自己每改一处逻辑就把 6.3 那张表从头跑一遍尤其是贴尾转弯和连按转向这两项它们从来不报错只在你演示的时候翻车。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑