简介:这份面向 C++ 初学者、课程设计练习者与游戏开发入门者的 DevC++ 贪吃蛇小游戏源码包,围绕控制台游戏开发提供可编译运行的完整示例,帮助读者把结构体、数组、条件判断、循环和键盘输入等语法落实到具体项目中。压缩包共 9 个文件,约 112KB,以 cpp 源文件、h 头文件及 Dev-C++ 工程配置为主,并附 exe 可执行文件与编译中间文件,既可先运行观察效果,也方便对照工程组织方式继续修改。已有 11541 人学习,作为入门练手项目的参考热度较可观。读者还能梳理蛇身与坐标管理、方向控制、难度速度调整、边界与自撞判定、随机食物刷新、计分以及暂停计时等模块的实现思路,并观察一个控制台小游戏如何拆分为源码、头文件和工程配置,适合边运行边调试,积累排错经验与结构化编程方法。
1. 一个黑窗口里的完整游戏循环:DevC++ 贪吃蛇小游戏到底练的是什么
很多人第一次打开 Dev-C++,想写点"能玩"的东西,最后跑起来的多半还是贪吃蛇。这个 DevC++ 贪吃蛇小游戏没有图形库、没有引擎,只有一个字符界面和一条用 C++ 数组(或 vector)表示的蛇:方向键控制、吃到食物变长、撞墙或者咬到自己就结束。它能给那些刚学完循环和数组、想验证一下自己到底能不能写出"一个完整的东西"的人用。整个工程里最值钱的不是蛇长什么样,而是它用最小的代码量把"初始化、输入、更新、渲染、判定结束"这条游戏主循环完整走了一遍,后面你换引擎、换语言,套路还是这一个。
2. 工程结构与技术选型:四个系统头文件撑起一个可玩项目
2.1 为什么用字符网格,而不是图形库
先固定一个前提:这份源码面向 Windows 控制台,用 Dev-C++ 自带的 MinGW(GCC)工具链编译,不引入 EasyX、SDL 这类第三方库。这个选择会带来一连串后果:蛇是可数的方格,渲染就是往一个二维字符缓冲里写@、o、*,碰撞检测就是整数坐标比较,连浮点误差都不会出现。
对入门项目来说这是好事。图形库的第一步往往是配置库路径、链接-lgraphics、处理 32/64 位不匹配,很多人卡在这一步就直接放弃了,真正的游戏逻辑一行没写。控制台方案把门槛压到最低:一个.cpp文件加#include <conio.h>就能跑出画面。
代价也要说清楚。控制台不是为实时交互设计的,字符刷新靠光标定位,输入靠轮询而不是事件回调,跨平台支持也基本没有——conio.h是 Windows 工具链的东西,换到别的系统上得改。所以定位要摆正:这是学循环、学状态机、学模块拆分的项目,不是要做成上架产品。
依赖只有这几项,Dev-C++ 环境下开箱可用:
| 头文件 | 用途 | 备注 |
|---|---|---|
<conio.h> | _kbhit()判断有无按键、_getch()无回显取键 | Windows/MinGW 提供,非标准 |
<windows.h> | SetConsoleCursorPosition定位光标、GetTickCount计时、Sleep让出 CPU | 只在渲染和主循环用到 |
<cstdlib>/<ctime> | rand()、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-C++,
File → New → Project,选Console Application,语言选C++,工程名填SnakeGame,保存到一个全英文、无空格的路径下。中文路径在部分 MinGW 版本上会让编译器的临时文件处理出问题,这类问题排查起来很费时间,不如一开始就避开。 - 右键工程树里的源文件节点,
Add把main.cpp、game.cpp、render.cpp、input.cpp加进来;头文件也加进去,方便在 IDE 里跳转。 Project → Project Options → Parameters,链接器一般不用额外加东西,因为windows.h对应的库 MinGW 默认就链上了。- 编译选项建议开
-std=c++11和-Wall。-Wall对一个贪吃蛇项目特别值——"有符号和无符号比较"这类警告,往往就是蛇明明没撞墙却突然结束的原因。 - 按
F11编译运行。控制台窗口默认可能是 80×25 字符,如果你的地图宽 30 高 20,刚好放得下;地图再大就得改窗口缓冲区大小,否则右边会被截断,看着像"蛇跑出屏幕"。
如果不想开 IDE,命令行等价操作是这样,方便你在 CI 或者脚本里跑:
# 在工程目录下,用 MinGW 的 g++ 编译 g++ main.cpp game.cpp render.cpp input.cpp -o SnakeGame.exe -std=c++11 -Wall # 直接跑 ./SnakeGame.exe参数含义很直白:-std=c++11是为了能用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 相差 2,RIGHT 和 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::vector<Point>& 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::vector<Point> 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) % 4,UP(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 控制台默认代码页不是这个,两套编码对不上。
解决:三种都行,任选一种坚持用——把源文件另存为 ANSI(GBK);或者在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持有一个Config,W/H从静态常量改成运行时读cfg.width。注意render.cpp里那个static char grid[H][W]就不能用静态数组了,改成每帧按尺寸resize的std::vector<char>,或者用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::vector<Point> obstacles_,碰撞判定里多一个循环;加穿墙就是把撞墙那条over_ = true换成取模回绕;存最高分用一个std::ifstream/ofstream读写本地文本文件,路径别写绝对路径。每次改动前我都会先想清楚"这个改动会不会破坏Game不碰输出的约定"——一旦破了,测试和渲染都会跟着受影响。
血泪经验是:别一次性改三样。我见过有人一边改地图尺寸一边加重构一边调速度曲线,最后跑起来画面错位,完全分不清是哪一处改动带来的。从那以后我每次动核心规则前,都强制先跑一遍无头断言,绿了再动渲染。希望帮到你。
本文还有配套的精品资源,点击获取