资讯动态

DevC++贪吃蛇小游戏实战:主循环、字符渲染与输入避坑

发布时间:2026/10/24 2:33:31 来源:尧图企业网站定制
简介这份面向 C 初学者、课程设计练习者与游戏开发入门者的 DevC 贪吃蛇小游戏源码包围绕控制台游戏开发提供可编译运行的完整示例帮助读者把结构体、数组、条件判断、循环和键盘输入等语法落实到具体项目中。压缩包共 9 个文件约 112KB以 cpp 源文件、h 头文件及 Dev-C 工程配置为主并附 exe 可执行文件与编译中间文件既可先运行观察效果也方便对照工程组织方式继续修改。已有 11541 人学习作为入门练手项目的参考热度较可观。读者还能梳理蛇身与坐标管理、方向控制、难度速度调整、边界与自撞判定、随机食物刷新、计分以及暂停计时等模块的实现思路并观察一个控制台小游戏如何拆分为源码、头文件和工程配置适合边运行边调试积累排错经验与结构化编程方法。1. 一个黑窗口里的完整游戏循环DevC 贪吃蛇小游戏到底练的是什么很多人第一次打开 Dev-C想写点能玩的东西最后跑起来的多半还是贪吃蛇。这个 DevC 贪吃蛇小游戏没有图形库、没有引擎只有一个字符界面和一条用 C 数组或 vector表示的蛇方向键控制、吃到食物变长、撞墙或者咬到自己就结束。它能给那些刚学完循环和数组、想验证一下自己到底能不能写出一个完整的东西的人用。整个工程里最值钱的不是蛇长什么样而是它用最小的代码量把初始化、输入、更新、渲染、判定结束这条游戏主循环完整走了一遍后面你换引擎、换语言套路还是这一个。2. 工程结构与技术选型四个系统头文件撑起一个可玩项目2.1 为什么用字符网格而不是图形库先固定一个前提这份源码面向 Windows 控制台用 Dev-C 自带的 MinGWGCC工具链编译不引入 EasyX、SDL 这类第三方库。这个选择会带来一连串后果蛇是可数的方格渲染就是往一个二维字符缓冲里写、o、*碰撞检测就是整数坐标比较连浮点误差都不会出现。对入门项目来说这是好事。图形库的第一步往往是配置库路径、链接-lgraphics、处理 32/64 位不匹配很多人卡在这一步就直接放弃了真正的游戏逻辑一行没写。控制台方案把门槛压到最低一个.cpp文件加#include conio.h就能跑出画面。代价也要说清楚。控制台不是为实时交互设计的字符刷新靠光标定位输入靠轮询而不是事件回调跨平台支持也基本没有——conio.h是 Windows 工具链的东西换到别的系统上得改。所以定位要摆正这是学循环、学状态机、学模块拆分的项目不是要做成上架产品。依赖只有这几项Dev-C 环境下开箱可用头文件用途备注conio.h_kbhit()判断有无按键、_getch()无回显取键Windows/MinGW 提供非标准windows.hSetConsoleCursorPosition定位光标、GetTickCount计时、Sleep让出 CPU只在渲染和主循环用到cstdlib/ctimerand()、srand()、time()食物随机生成vector/string蛇身容器、整帧拼接纯标准库2.2 目录怎么拆main / game / render 三层职责单人写的小游戏最容易写成一个main.cpp从头撸到尾三四百行之后改一个碰撞规则都要翻半天。我一般会按规则、显示、驱动三层拆开即使项目小也先拆因为后面加关卡、加道具时你会庆幸当初拆了。SnakeGame/ ├── SnakeGame.dev // Dev-C 工程文件双击打开 ├── main.cpp // 入口播种随机数、建 Game、跑主循环 ├── game.h // 数据结构和规则接口不碰任何输出 ├── game.cpp // tick()、碰撞、食物生成、计分 ├── render.h ├── render.cpp // 光标定位、整帧拼串、一次性输出 └── input.h/.cpp // 键盘轮询把按键翻译成方向各层的边界要守住game.h/game.cpp里不允许出现cout、printf、gotoxy。它只回答这一步发生了什么、蛇死没死输出的活全部交给render。这一条守住了你后面写无头测试不画屏只跑规则就是顺手的事。render.cpp只读Game的 getter不改它的内部状态。渲染层要是能偷偷改蛇的位置调试时会非常痛苦。main.cpp只做三件事初始化、循环、收尾。里面出现业务判断就说明职责漏了。2.3 在 Dev-C 里把工程跑起来从零建工程的操作路径大致是这样顺序别乱打开 Dev-CFile → New → Project选Console Application语言选C工程名填SnakeGame保存到一个全英文、无空格的路径下。中文路径在部分 MinGW 版本上会让编译器的临时文件处理出问题这类问题排查起来很费时间不如一开始就避开。右键工程树里的源文件节点Add把main.cpp、game.cpp、render.cpp、input.cpp加进来头文件也加进去方便在 IDE 里跳转。Project → Project Options → Parameters链接器一般不用额外加东西因为windows.h对应的库 MinGW 默认就链上了。编译选项建议开-stdc11和-Wall。-Wall对一个贪吃蛇项目特别值——有符号和无符号比较这类警告往往就是蛇明明没撞墙却突然结束的原因。按F11编译运行。控制台窗口默认可能是 80×25 字符如果你的地图宽 30 高 20刚好放得下地图再大就得改窗口缓冲区大小否则右边会被截断看着像蛇跑出屏幕。如果不想开 IDE命令行等价操作是这样方便你在 CI 或者脚本里跑# 在工程目录下用 MinGW 的 g 编译 g main.cpp game.cpp render.cpp input.cpp -o SnakeGame.exe -stdc11 -Wall # 直接跑 ./SnakeGame.exe参数含义很直白-stdc11是为了能用nullptr、范围 for 这些写起来省事的语法-Wall打开常用警告-o指定输出文件名不加的话默认叫a.exe以后工程一多就分不清哪个是哪个了。提示如果你拿到的是单个大文件版本所有代码在一个main.cpp里先别急着拆。先编译跑通、确认能玩再按 2.2 的分层慢慢挪出去每挪一层编译一次出问题能立刻定位到是哪一步拆坏的。3. 核心数据结构与游戏循环蛇身、方向、一帧的完整顺序3.1 蛇身用 vector 还是 deque蛇在这个游戏里本质是一个有序的点序列头在最前尾在最后。每一帧要么头进、尾出没吃到要么头进、尾不动吃到了。这个操作模式用std::vector的insert(begin())pop_back()就能实现代价是每次头插要整体搬移——但蛇最长也就几百节这点开销完全无感。用std::deque的push_front()在理论上更合适O(1)头插用固定长度数组加头尾下标循环也是一种写法老教材里常见。我倾向vector理由是可读性body_[0]是头、body_.back()是尾索引和直觉一致写碰撞循环时不容易绕晕。等你真做到蛇长几千节的变态模式再换deque也不迟。坐标和方向的定义// game.h —— 只放数据和规则接口不碰任何输出 #pragma once #include vector struct Point { int x; int y; bool operator(const Point o) const { return x o.x y o.y; } }; // 顺序很关键UP 和 DOWN 相差 2RIGHT 和 LEFT 相差 2 // 后面判反向直接用 (dir 2) % 4 就够了 enum Dir { DIR_UP 0, DIR_RIGHT, DIR_DOWN, DIR_LEFT }; class Game { public: static const int W 30; // 横向格数 static const int H 20; // 纵向格数 Game(); void reset(); bool tick(); // 推进一步返回 false 表示这局结束 void setNextDir(Dir d); const std::vectorPoint body() const { return body_; } Point food() const { return food_; } int score() const { return score_; } int speedMs()const { return speedMs_; } bool over() const { return over_; } private: std::vectorPoint body_; // body_[0] 是蛇头 Point food_; Dir dir_; // 当前生效方向 Dir pendingDir_; // 本帧待生效方向 int score_; int speedMs_; // 每步间隔越小越快 bool over_; void spawnFood(); };enum的顺序不是随手排的是为了后面用(dir_ 2) % 4一行算出反方向。这种让数据布局替你做判断的小技巧在规则代码里能省掉一整个switch。dir_和pendingDir_分成两个变量是这套代码里最重要的一个设计。原因在 3.2 节会讲清楚。3.2 一帧到底做了什么tick 函数的顺序tick()是整个项目的核心它把一个移动拆成五步顺序不能乱bool Game::tick() { if (over_) return false; // 1) 本帧的转向只在这里生效一次 // 如果新方向和当前方向正好相反丢弃这次输入防止蛇头直接拧回来咬自己 if (pendingDir_ ! (Dir)((dir_ 2) % 4)) { dir_ pendingDir_; } // 2) 先算新头位置此时还没改 body_ Point head body_.front(); switch (dir_) { case DIR_UP: head.y - 1; break; case DIR_RIGHT: head.x 1; break; case DIR_DOWN: head.y 1; break; case DIR_LEFT: head.x - 1; break; } // 3) 撞墙注意是 W 和 H不是 if (head.x 0 || head.x W || head.y 0 || head.y H) { over_ true; return false; } // 4) 撞自己没吃到食物时尾巴这一帧会挪走所以最后一节不算障碍 bool eat (head food_); size_t checkLen eat ? body_.size() : body_.size() - 1; for (size_t i 0; i checkLen; i) { if (body_[i] head) { over_ true; return false; } } // 5) 落子 body_.insert(body_.begin(), head); if (eat) { score_ 10; if (speedMs_ 60) speedMs_ - 3; // 每吃一个提速留一个下限 spawnFood(); } else { body_.pop_back(); } return true; }逐段说清楚为什么这么排第 1 步放在最前面是因为一个真实 bug玩家手速快的时候一帧之内可能按下上再按左如果每一帧都直接改写dir_蛇头方向会在同一帧里被连改两次判定用的方向和实际移动方向就对不上了。用pendingDir_缓冲一帧只认一次输入再密集也最多前进一格。反向检查用(dir_ 2) % 4UP(0)的反向是DOWN(2)RIGHT(1)的反向是LEFT(3)一行通吃。第 2 步先算再改。如果先insert到body_里再算新位置你会发现循环判断时蛇头已经和原蛇头重合逻辑会乱。先算出候选位置所有判定都基于它通过了再真正落子这是写格子类游戏的好习惯。第 3 步的边界。地图坐标是0..W-1所以撞墙条件是head.x W。写成head.x W会导致蛇能从最右边钻出去一格才死看起来像穿墙了但又没完全穿。第 4 步的checkLen是新手最容易写错的一行。没吃食物时蛇尾会在同一帧里移走那么新头正好落在旧尾巴的位置其实是合法的不算咬到自己。只有当吃到食物、尾巴不动时才需要连最后一节一起判。这个细节不处理表现是蛇长度到 4 以上的某个特定转身动作里会莫名其妙结束而且极难复现。第 5 步insert到头部的复杂度是O(n)但 n 最多几百一秒钟最多几十帧这点开销在控制台游戏里完全不是瓶颈。真在意的话换成deque::push_front接口改动很小。3.3 食物生成与速度曲线两个容易写崩的参数食物生成看起来最简单实际上有两个要注意的点void Game::spawnFood() { int freeCell W * H - (int)body_.size(); if (freeCell 0) { over_ true; return; } // 满屏了就当通关 // 随机采样 撞上蛇身就重试 for (int guard 0; guard 10000; guard) { Point p; p.x rand() % W; p.y rand() % H; bool onSnake false; for (size_t i 0; i body_.size(); i) { if (body_[i] p) { onSnake true; break; } } if (!onSnake) { food_ p; return; } } // 重试次数用尽还找不到蛇快占满地图时可能出现扫一遍找第一个空位 for (int y 0; y H; y) for (int x 0; x W; x) { Point p; p.x x; p.y y; bool onSnake false; for (size_t i 0; i body_.size(); i) if (body_[i] p) { onSnake true; break; } if (!onSnake) { food_ p; return; } } }参数说明与逻辑freeCell是空格数蛇填满地图时直接判通关否则后面的重试循环会变成死循环。guard 10000是一个保险丝——蛇占地图 90% 以上时随机采样命中空位的概率很低理论上可能连续空转很久。加上兜底的顺序扫描最坏情况也能在O(W*H*len)内结束。这份代码在蛇短的时候几乎永远走不到兜底分支但它存在本身就是心理防线没有这一层玩到后期偶尔卡住一下你会以为是电脑卡了。速度控制我一般这么设speedMs_初始 180约 5.5 帧/秒的逻辑步进已经是能玩的手感每吃一个减 3ms下限 60。下限必须有否则后期速度降到 20ms 时你按方向键的手速跟不上操作体验会很差。速度值不要直接写死在Sleep里让它成为Game的一个成员好处是后面加难度选择关卡加速时改的是一个变量而不是满屏找魔数。4. 渲染与输入整帧拼接消闪烁_kbhit 非阻塞取键4.1 不闪屏的字符渲染字符界面贪吃蛇最经典的画面问题就是闪。新手写法是每帧system(cls)清屏再重画屏幕会明显一顿一顿蛇走得越快闪得越厉害。原因是cls会清空整个控制台缓冲区再重绘视觉上就是一次全白再全黑。正确做法是把这一帧的所有字符先拼成一个字符串定位光标回到左上角一次性写出去。控制台的光标移动开销远小于清屏一次性写还能让终端只做一次刷新// render.cpp #include windows.h #include cstdio #include cstring #include string #include game.h static void gotoxy(int x, int y) { COORD c; c.X (SHORT)x; c.Y (SHORT)y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), c); } void drawFrame(const Game g) { // 静态缓冲避免每帧在栈上分配 static char grid[Game::H][Game::W]; memset(grid, , sizeof(grid)); // 先铺蛇身body_[0] 是头用 其余用 o for (size_t i 0; i g.body().size(); i) { Point p g.body()[i]; if (p.x 0 p.x Game::W p.y 0 p.y Game::H) grid[p.y][p.x] (i 0) ? : o; } // 再落食物覆盖掉可能重叠的位置 Point f g.food(); if (f.x 0 f.x Game::W f.y 0 f.y Game::H) grid[f.y][f.x] *; std::string buf; buf.reserve((Game::W 1) * Game::H 64); for (int y 0; y Game::H; y) { buf.append(grid[y], Game::W); buf \n; } // 计分行接在帧尾跟着同一次刷新写出去避免额外的光标跳转 char line[64]; snprintf(line, sizeof(line), score: %d speed: %dms , g.score(), g.speedMs()); buf line; gotoxy(0, 0); fwrite(buf.data(), 1, buf.size(), stdout); fflush(stdout); }逻辑说明整帧固定输出H行、每行W个字符加上行尾换行长度完全恒定。这一点很关键——只要每帧输出长度不变光标回位后覆盖的就是上一帧的内容不会留下残影。如果哪一帧少输出一个字符后面的内容就整体错位屏幕上会出现蛇的残像这种问题在调试时很折磨人。fwrite比循环cout 快一个数量级因为它只做一次缓冲拷贝。fflush(stdout)是保险避免在某些环境下输出滞留导致画面延迟一拍。参数说明表示蛇头、o表示蛇身、*表示食物换字符只需要改这三个地方。真要在字符终端里做出立体感还可以用SetConsoleTextAttribute给蛇头单独上色但那是锦上添花先把不闪屏做对。4.2 _kbhit _getch非阻塞输入与方向键前缀标准cin 是阻塞的按一下才走一步游戏就变成回合制了。控制台游戏靠conio.h提供的两个函数做轮询_kbhit()缓冲区里有没有待处理的按键返回非 0 表示有不阻塞。_getch()立刻取一个字符不回显到屏幕。方向键的坑在编码上。方向键不是单个 ASCII 字符按下时缓冲区里会出现两个字节先是一个前缀通常是 0 或 224第二个才是真正的扫描码。如果你只_getch()一次那个前缀会被当成普通字符方向键就完全失灵而且缓冲区里会残留一个字节导致后续按键全部错位一格——这是控制台游戏里出现频率很高的输入 bug。// input.cpp #include conio.h #include game.h // 返回 true 表示解析出了一个有效方向 static bool readKey(Dir out) { if (!_kbhit()) return false; int c _getch(); if (c 0 || c 224) { // 方向键/功能键的前缀字节 c _getch(); // 第二个字节才是扫描码 switch (c) { case 72: out DIR_UP; return true; case 77: out DIR_RIGHT; return true; case 80: out DIR_DOWN; return true; case 75: out DIR_LEFT; return true; default: return false; // 其他功能键忽略 } } switch (c) { case w: case W: out DIR_UP; return true; case d: case D: out DIR_RIGHT; return true; case s: case S: out DIR_DOWN; return true; case a: case A: out DIR_LEFT; return true; default: return false; } }逻辑说明72/80/75/77是上、下、左、右的扫描码属于 Windows 控制台沿用的约定不用去背写在注释里就行。同时支持 WASD 是为了照顾笔记本键盘——方向键在小键盘区长时间玩手会酸。这里有个细节值得强调函数一次只消费一个按键。主循环里应该用while (readKey(d))把缓冲区里攒下的按键全部取出来只保留最后一个作为本帧的方向而不是每帧取一个。前者手感跟手后者在快速连按时会有明显延迟。4.3 主循环Sleep 只负责让出 CPU不负责计时主循环把前三块拼起来// main.cpp #include windows.h #include cstdlib #include ctime #include game.h #include render.h #include input.h int main() { srand((unsigned)time(NULL)); // 不播种的话每局食物序列一模一样 Game g; DWORD last GetTickCount(); DWORD acc 0; while (!g.over()) { // 1) 消费掉这一帧攒下的所有按键 Dir d; while (readKey(d)) g.setNextDir(d); // 2) 用真实流逝时间累加而不是靠 Sleep 的次数 DWORD now GetTickCount(); acc now - last; last now; if (acc (DWORD)g.speedMs()) { acc 0; g.tick(); // 逻辑推进 } drawFrame(g); // 每帧都重绘画面才跟手 Sleep(10); // 只负责让出 CPU } gotoxy(0, Game::H 2); printf(GAME OVER score%d\n, g.score()); system(pause); return 0; }为什么不用Sleep(speedMs)直接控速Sleep的精度受系统调度影响请求 100ms 实际可能睡 115ms而且误差会累积。等你把速度调到 60ms 附近实际帧率会明显偏慢玩家会觉得明明调快了还是卡。用GetTickCount()读真实毫秒数累加只在累加值超过阈值时才tick()一次逻辑步进就和渲染帧率解耦了屏幕以 10ms 一次的频率刷新画面顺滑逻辑按speedMs_推进节奏稳定。参数说明Sleep(10)里的 10 是渲染间隔不是游戏速度。调小会让 CPU 占用升高调大会让按键响应变迟钝10 到 16 之间是比较舒服的区间。acc用DWORD无符号 32 位而不是int避免长时间运行后溢出成负数。5. 避坑与排查控制台贪吃蛇最容易出问题的五个地方5.1 编译与显示上的三个坑坑一conio.h: No such file or directory。现象换了个编辑器或者换了个编译器一编译就报找不到conio.h。原因conio.h不是 C 标准头文件是 Windows 工具链MinGW、MSVC自带的扩展。用非 Windows 工具链编译必然找不到Dev-C 里如果选错了编译器配置比如误选了某个不带这个头的工具集也会出现。解决先确认工程用的是 MinGW 的 GCC 编译器如果必须跨平台把_kbhit和_getch抽到一个platform_input.h里Windows 分支用conio.h其他分支用终端原始模式读取主循环的代码一行都不用改。这就是 2.2 节把输入单独拆一层的好处。坑二中文注释和输出全是乱码。现象代码里的中文注释变成一堆方块printf(游戏结束)打出来也是乱码。原因源文件保存编码和控制台代码页不一致。Dev-C 默认可能把源码存成 UTF-8而 Windows 控制台默认代码页不是这个两套编码对不上。解决三种都行任选一种坚持用——把源文件另存为 ANSIGBK或者在main开头调SetConsoleOutputCP(936)把输出代码页固定住或者干脆界面文字全用英文注释用中文但单独存。最忌讳的是这台机器好了换台机器又乱所以在项目里定死一种写进 README。坑三屏幕疯狂闪烁蛇走得越快闪得越厉害。现象画面一顿一顿像老电视信号不好。原因每帧调用了system(cls)或system(clear)。这相当于每帧都新建一个进程去清屏开销极大而且清屏到重绘之间必然有一段全白。解决换成 4.1 节的整帧拼串 光标回位 一次性写出。如果不想用 Windows API退一步的做法是只重绘蛇头和新食物、用空格擦掉旧尾巴也能消掉大部分闪烁但代码复杂度会上升不如直接上gotoxy干净。5.2 玩法逻辑上的两个坑坑四按了反方向键蛇立刻原地撞死自己。现象蛇向右走的时候快速按一下左游戏直接结束而且位置看起来没动。原因dir_被输入直接改写成了反方向下一步算出来的新头和第二节重合撞自己判定成立。更隐蔽的情况是一帧内连按两次方向键比如上再左两次都写进了同一个变量最后生效的那个可能和当前方向相反。解决一是像 3.2 节那样用pendingDir_缓冲一帧只认一次二是在setNextDir里就过滤掉反向输入让脏数据根本进不来void Game::setNextDir(Dir d) { if (d (Dir)((dir_ 2) % 4)) return; // 反向直接丢弃 pendingDir_ d; }加完之后玩家按错方向只是没反应而不是送死手感差别很大。坑五每局的食物出现位置完全一样。现象关掉重开十次第一个食物永远在同一个格子上。原因rand()没播种或者用了固定的种子。不调srand的话每次进程启动的随机序列是一样的。解决main里第一件事就是srand((unsigned)time(NULL))。用time(NULL)做种子还有个副作用——同一秒内连续启动两次序列仍然相同正常玩不会遇到但如果你写自动化测试就会受影响测试里应该显式传入固定种子让结果可复现。注意上面五个坑里坑四和坑五都属于能跑但不对的类型编译器不会报任何错。写完一定要连着玩三五分钟特别是快速连按方向键、蛇长到十几节的时候再转身这两个场景能逼出大部分隐藏的逻辑问题。6. 二次开发改造把魔数抽出去再用无头测试守住规则玩通之后想改点什么通常卡在同一个地方代码里到处是30、20、180、10这种数字改地图尺寸要全局搜索替换替换漏一处地图就错位。第一步改造应该是把这些配置收拢。// config.h #pragma once struct Config { int width 30; // 地图宽格 int height 20; // 地图高格 int initSpeed 180; // 初始每步间隔 ms int minSpeed 60; // 速度下限防止快到看不清 int speedUp 3; // 每吃一个提速多少 ms int scorePerFood 10; bool wrapWall false; // true 则撞墙从对面出来 };Game持有一个ConfigW/H从静态常量改成运行时读cfg.width。注意render.cpp里那个static char grid[H][W]就不能用静态数组了改成每帧按尺寸resize的std::vectorchar或者用std::string拼行——反正整帧拼串的做法本来就不依赖固定大小。第二个改动建议是给规则加无头测试。因为Game完全不碰输出你可以脱离控制台跑它// test_tick.cpp —— 不画屏只验规则 #include cassert #include game.h int main() { Game g; // 初始 3 节朝右 Point head0 g.body().front(); g.tick(); // 走一步 Point head1 g.body().front(); assert(head1.x head0.x 1 head1.y head0.y); // 确实右移了一格 g.setNextDir(DIR_LEFT); // 反向输入 g.tick(); assert(g.body().front().x head1.x 1); // 仍然是右移反向被丢弃 g.setNextDir(DIR_UP); g.tick(); assert(g.body().front().y head1.y - 1); // 正常转向生效 return 0; }这套断言能在你改tick()顺序的时候第一时间告诉你哪里坏了。像 3.2 节提到的尾巴该不该算障碍的边界靠手动玩很难稳定复现靠测试三行就锁住了。再往后就看你想要什么了加障碍物就是在Game里多一个std::vectorPoint obstacles_碰撞判定里多一个循环加穿墙就是把撞墙那条over_ true换成取模回绕存最高分用一个std::ifstream/ofstream读写本地文本文件路径别写绝对路径。每次改动前我都会先想清楚这个改动会不会破坏Game不碰输出的约定——一旦破了测试和渲染都会跟着受影响。血泪经验是别一次性改三样。我见过有人一边改地图尺寸一边加重构一边调速度曲线最后跑起来画面错位完全分不清是哪一处改动带来的。从那以后我每次动核心规则前都强制先跑一遍无头断言绿了再动渲染。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑