ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Dev-C++ 六个 C++ 小游戏代码合集:从猜数字到 2048 控制台实战

Dev-C++ 六个 C++ 小游戏代码合集:从猜数字到 2048 控制台实战 Dev-C 这个只有几十兆的小 IDE很多人的第一个 C 程序就是在它上面敲出来的新建源代码、F11 编译运行、黑框一闪屏幕上跳出Hello World。它的定位很清晰——上手快、安装包小、自带编译器不用折腾环境变量。也正是因为这一点它特别适合拿来跑一些短小精悍的控制台游戏代码。我这次整理的就是这么六个 C 游戏代码全部单文件、零第三方库依赖Dev-C 里新建一个源文件、粘贴、按 F11 就能跑想改数值、改难度也只需要动几个常量。这六个东西从最简单的猜数字一路排到 2048覆盖了随机数、二维数组、非阻塞按键、光标定位、递归生成算法这些入门到进阶的常见知识点。适合刚学完语法想找点东西练手的人也适合拿去做课堂演示或者给社团新人当练题。下面我把它们逐个拆开讲代码、原理、坑点、调参思路都摊开说清楚。1. 六个游戏的选型逻辑与整体设计思路1.1 为什么清一色选控制台程序一开始我也想过用图形库比如 EasyX 或者 SFML画个真正的窗口、贴几张素材图视觉上确实唬人。但真放到可复制、能直接跑这个前提下图形库反而变成了负担EasyX 要额外装库并配置包含目录和库目录SFML 要下载预编译包、手动改链接选项任何一个环节配错新手就会卡在undefined reference上出不来代码还没看就被环境劝退了。控制台程序的好处在于它只依赖 C 标准库加上 Windows 系统自带的控制台接口Dev-C 自带的那套编译器直接就认。不需要任何附加配置粘贴进去按 F11能编过就能跑。而且控制台程序的调试成本极低——出问题基本就是逻辑问题不会出现到底是代码写错了还是环境没配好这种扯皮。另外还有个很实际的原因控制台游戏逼着你去处理输入、状态、刷新这三件事。图形库里这些都封装好了你调用一下就完事控制台里你得自己用_kbhit()轮询按键、自己控制重绘节奏、自己维护坐标数组。这恰恰是入门阶段最该练的手感——把游戏循环输入 → 更新 → 渲染这条主线走通一遍以后换到任何框架都是同一套骨架。1.2 六个游戏的难度阶梯怎么排的这六个不是随便凑的是有意排成一条坡道序号游戏核心知识点代码规模难度1猜数字随机数种子、输入校验、循环控制约 35 行入门2井字棋二维数组、胜负判定、平局判定约 70 行入门进阶3贪吃蛇数组模拟队列、非阻塞输入、整屏重绘约 110 行中级4射击训练场光标定位、毫秒计时、命中率统计约 90 行中级5迷宫探险递归回溯生成、坐标移动、终点判定约 95 行中级进阶62048 简化版方向归并、状态变化检测、死局判定约 130 行进阶你会发现从第 3 个开始游戏就不再是你输一句我回一句的对话式交互了变成实时循环。这是分水岭一旦上了实时循环帧率、输入缓冲、屏幕刷新这三个问题就同时冒出来处理不好游戏就会有明显的卡顿感或者按键粘滞感。我在后面第 2 章专门把这三个问题拎出来讲。1.3 面向 Dev-C 的兼容性取舍写代码之前得先摸清 Dev-C 的脾气。市面上流通最广的 Orwell Dev-C 5.11自带的是 TDM-GCC 4.9.2编译器本身是能认 C11 的但 IDE 默认的编译参数比较保守一些较新的语法糖不一定会自动打开。所以我在写这六个代码时做了几个刻意的取舍不用auto、不用范围 for、不用nullptr、不用智能指针全部写成 C98 也能通过的写法。这样即使你没动过编译选项也能一次编过。不用thread和chrono。TDM-GCC 4.9.2 支持线程但需要手动加-pthread多一层配置就多一个失败点不如用Sleep()和clock()解决。文件读写、网络、图形接口一律不碰只用定型好的iostream、cstdlib、ctime、conio.h、windows.h。匈牙利命名和花括号风格偏传统using namespace std;直接摊开写不搞命名空间嵌套。如果你是玩小熊猫 Dev-CRed Panda Dev-C或者干脆换到 VS Code 配 MinGW这六个代码同样能跑两者对 conio.h 和 windows.h 的支持是一致的。区别只在于小熊猫的代码补全和报错提示更友好一点你抄代码时打错字能被更早发现。2. 控制台游戏绕不开的四个核心技术点2.1 非阻塞按键_kbhit 与 _getch 的正确组合控制台里读按键新手最顺手的是cin 但在游戏里它基本没法用——cin是阻塞的、行缓冲的你不按回车它就一直等游戏画面直接冻住。实时游戏要的是看一眼键盘有没有动静有就处理没有就继续跑这就是conio.h里两个函数的用途。_kbhit()的作用是检查输入缓冲区里有没有待处理的字符有返回非零没有返回 0它不会把字符取走。_getch()则是立刻取走一个字符且不回显、不需要回车。两者配合的标准写法是while (_kbhit()) { // 把缓冲区里的按键一次性取空 int c _getch(); // 处理 c }这里有个细节必须说一下为什么按键处理要用while而不是if。因为如果玩家手快在半秒内连按了三次方向键输入缓冲里就躺了三个字节。用if每帧只取一个剩余的两个会积压下来蛇就会在你不按键的时候继续转向手感非常怪。用while一次取空只保留最后一次有效转向手感立刻正常。另一个坑是方向键。方向键不是单字节_getch()第一次返回的是扩展码 224部分编译器上是 0第二次才是实际的键码上 72、下 80、左 75、右 77。所以处理方向键必须连着读两次int c _getch(); if (c 224) { c _getch(); if (c 72) { /* 上 */ } else if (c 80) { /* 下 */ } else if (c 75) { /* 左 */ } else if (c 77) { /* 右 */ } }至于_getch前面的下划线这里要留个心眼。MinGW 系头文件里同时提供了_getch/_kbhit和老式命名getch/kbhit绝大多数版本两个都认。但万一报未在此作用域中声明别慌把带下划线的名字去掉下划线再编一次就行这是最常见的编译差异之一。2.2 屏幕刷新system(cls) 什么时候能用什么时候会拖后腿控制台游戏第二件事是怎么让画面动起来。最省事的写法是每帧开头来一句system(cls)清屏然后重新打印整个画面。很多教程都这么写因为它直观一行代码解决一切。但在实时循环里它有代价。system()的本质是启动一个子进程去执行cls命令每次调用都要创建一个进程、加载一次命令解释器。静态游戏比如井字棋每回合调一次完全无所谓到了贪吃蛇这种每秒刷新七八次的场景每秒七八个进程被拉起来又销毁画面会明显一顿一顿任务管理器里还能看到 CPU 占用跳起来。所以我在这六个代码里统一用另一种方式gotoxy()把光标挪回左上角然后直接覆盖输出全程不清屏。void gotoxy(int x, int y) { COORD p; p.X (SHORT)x; p.Y (SHORT)y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), p); }顺手再加一个隐藏光标的辅助函数不然字符画上总有个跳动的光标在闪void hideCursor() { CONSOLE_CURSOR_INFO ci; GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); ci.bVisible FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); }还有个更细的优化点输出时先拼成一个字符串再整体打印比逐字符cout要快、要稳。原因是逐字符输出会触发多次控制台写入调用字符越多开销越大画面边缘就容易出现撕裂和闪烁。贪吃蛇里我用的是一个二维字符数组当缓冲区先把边框、蛇身、食物全部填进去最后按行一次性cout一帧只做二十来次输出调用观感干净很多。2.3 计时与帧率Sleep 和 clock 各管什么实时游戏里Sleep()和clock()是两个方向完全不同的工具混用会出问题。Sleep(ms)是让当前线程挂起指定的毫秒数。它的作用是把循环速度压下来——如果你不Sleep贪吃蛇的循环会以几万帧每秒的速度狂奔蛇一秒能走几百格玩都没法玩。贪吃蛇里我用speed变量控制它初始 150 毫秒走一格每吃一个食物减 4 毫秒最低压到 60 毫秒这样游戏会随着得分越来越快越来越紧张。clock()是取当前进程的累计运行时间配合CLOCKS_PER_SEC换算成秒用来做倒计时或者计分。射击训练场里那个 30 秒限时就是靠它实现的clock_t t0 clock(); double sec (double)(clock() - t0) / CLOCKS_PER_SEC; if (sec 30.0) { /* 结束 */ }Windows 平台上clock()的实测行为接近墙钟时间用来做秒级倒计时完全够用。如果你的需求是精确到帧的物理计算那得换GetTickCount64()或者QueryPerformanceCounter()不过对这几个小游戏来说属于杀鸡用牛刀。这里还有一个组合要点用了_kbhit()轮询之后循环里必须加Sleep否则 CPU 会被 100% 占满。射击游戏里我给的间隔是 30 到 50 毫秒也就是大概二十到三十帧的刷新率对字符界面来说肉眼看着是流畅的。2.4 编译选项与编码设置开局先把这两步做掉动手之前花两分钟把两个设置调好能省掉后面一大半的困惑。第一是编译标准。Dev-C 菜单栏依次点工具 → 编译选项 → 编译器勾选在编译时加入以下命令填上-stdc11虽然这六个代码按 C98 写也能过但把标准显式打开你之后自己往里加auto、vector初始化列表之类的写法时就不会莫名其妙报错。第二是文件编码。Dev-C 默认新建的源文件是 ANSI 编码在中文 Windows 上就是 GBK。如果你从别处复制了一段 UTF-8 的内容粘进去或者另存为时手滑选了 UTF-8运行起来所有中文提示都会变成一堆问号或者方块。解决办法是文件 → 另存为把编码切回 ANSI。实在被编码问题折腾烦了还有个一劳永逸的方案把游戏里的中文提示全改成英文只留代码注释用中文注释即使乱码也不影响运行。3. 六个游戏源码逐段拆解下面六段代码都是完整可编译的单文件直接新建源文件粘贴即可。我在每段后面说明关键参数和可以动手改的地方。3.1 猜数字把随机数和输入校验讲透这个游戏逻辑最简单但它是唯一一个把用户输入可能是垃圾这件事放在明面上的值得先写它。#include iostream #include cstdlib #include ctime using namespace std; int main() { srand((unsigned)time(0)); // 用当前时间做种子保证每次答案不同 int ans rand() % 100 1; // 1 ~ 100 int guess, cnt 0; cout 我已经想好了一个 1~100 之间的整数来猜猜看。 endl; while (true) { cout 第 (cnt 1) 次猜测请输入数字; if (!(cin guess)) { // 输入了非数字 cin.clear(); // 清错误状态 cin.ignore(10000, \n); // 丢掉这一行剩余内容 cout 只能输入数字重新来。 endl; continue; } cnt; if (guess ans) { cout 猜对了你用了 cnt 次。 endl; break; } if (guess ans) { cout 大了; if (guess - ans 5) cout 已经很接近了; } else { cout 小了; if (ans - guess 5) cout 已经很接近了; } cout endl; } return 0; }先说srand((unsigned)time(0))这行。rand()生成的是伪随机序列如果不播种每次运行程序得到的序列一模一样玩家玩第二次就发现规律了。用当前时间戳当种子是最简单的做法。再说输入校验。if (!(cin guess))这段是很多人第一版会漏掉的玩家输入一个字母会怎样cin进入错误状态后面的所有读取全部失效程序会陷入死循环疯狂打印同一个提示。所以必须clear()清状态、ignore()丢弃这行垃圾游戏才能继续。cnt计数本身也是个隐藏玩法1 到 100 的范围二分查找最多七次就能锁定答案。我在提示里加了接近了的反馈就是这个思路的雏形。你可以把它当作业让程序在玩家用了超过七次之后给一句其实你本来可以更快。3.2 井字棋二维数组与胜负判定的标准写法三子棋是用二维数组表达一个棋盘状态的经典教材。#include iostream using namespace std; char b[3][3]; void initBoard() { for (int i 0; i 3; i) for (int j 0; j 3; j) b[i][j] char(1 i * 3 j); // 格子编号 1~9 } void showBoard() { cout \n; for (int i 0; i 3; i) { cout b[i][0] | b[i][1] | b[i][2] \n; if (i 2) cout ---------\n; } cout \n; } char winner() { for (int i 0; i 3; i) { if (b[i][0] b[i][1] b[i][1] b[i][2]) return b[i][0]; if (b[0][i] b[1][i] b[1][i] b[2][i]) return b[0][i]; } if (b[0][0] b[1][1] b[1][1] b[2][2]) return b[0][0]; if (b[0][2] b[1][1] b[1][1] b[2][0]) return b[0][2]; return 0; } bool full() { for (int i 0; i 3; i) for (int j 0; j 3; j) if (b[i][j] 1 b[i][j] 9) return false; return true; } int main() { initBoard(); char cur X; int step 0; showBoard(); while (true) { cout 轮到 cur 落子请输入格子编号 (1-9); int n; if (!(cin n) || n 1 || n 9) { cin.clear(); cin.ignore(10000, \n); cout 输入不合法请输入 1 到 9 之间的整数。\n; continue; } int r (n - 1) / 3, c (n - 1) % 3; if (b[r][c] ! char(0 n)) { cout 这个位置已经被占了换一个。\n; continue; } b[r][c] cur; step; showBoard(); char w winner(); if (w X || w O) { cout 恭喜 w 方获胜\n; break; } if (step 9) { cout 平局棋逢对手。\n; break; } cur (cur X) ? O : X; } return 0; }两个设计点值得说。一是用1到9初始化棋盘这样棋盘既是显示内容又是这个格子是否被占的状态标记——判断占用只需要比较b[r][c] ! char(0 n)一行搞定不需要额外的布尔数组。这种一个变量干两件事的写法在小型程序里很划算但要注意它的边界如果游戏规模变大、状态变复杂这种技巧会变成维护负担那时候老老实实加一个bool used[3][3]更清晰。二是winner()的返回值。我让它返回获胜方的字符没赢就返回 0。这样一来main里只需要判断w X || w O。需要说明的是这个判定依赖棋盘上每个初始格子的字符都不同这个前提所以不会误判。如果你改成用 初始化空格就必须额外检查三个格子是不是都是空格否则空行会被当成获胜。3.3 贪吃蛇数组模拟队列加上整屏缓冲区重绘从这段开始进入实时游戏。整个代码核心就三件事蛇身用数组维护、按键非阻塞读取、画面整体重绘。#include iostream #include conio.h #include windows.h #include cstdlib #include ctime using namespace std; const int W 32; // 画面宽含边框 const int H 20; // 画面高含边框 int sx[600], sy[600]; // 蛇身坐标下标 0 是蛇头 int len, dirx, diry; int fx, fy, score, speed; bool over; char buf[H][W 1]; void gotoxy(int x, int y) { COORD p; p.X (SHORT)x; p.Y (SHORT)y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), p); } void hideCursor() { CONSOLE_CURSOR_INFO ci; GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); ci.bVisible FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); } void newFood() { bool bad; do { bad false; fx rand() % (W - 2) 1; fy rand() % (H - 2) 1; for (int i 0; i len; i) if (sx[i] fx sy[i] fy) bad true; } while (bad); } void init() { len 3; sx[0] W / 2; sy[0] H / 2; sx[1] W / 2 1; sy[1] H / 2; sx[2] W / 2 2; sy[2] H / 2; dirx -1; diry 0; score 0; speed 150; over false; srand((unsigned)time(0)); newFood(); } void draw() { for (int y 0; y H; y) { for (int x 0; x W; x) { if (y 0 || y H - 1 || x 0 || x W - 1) buf[y][x] #; else buf[y][x] ; } buf[y][W] \0; } buf[fy][fx] $; for (int i len - 1; i 0; i--) buf[sy[i]][sx[i]] (i 0) ? : o; gotoxy(0, 0); for (int y 0; y H; y) cout buf[y] \n; cout 得分: score 长度: len 方向键操作 / ESC 退出 flush; } void input() { while (_kbhit()) { int c _getch(); if (c 224) { c _getch(); if (c 72 diry ! 1) { dirx 0; diry -1; } else if (c 80 diry ! -1) { dirx 0; diry 1; } else if (c 75 dirx ! 1) { dirx -1; diry 0; } else if (c 77 dirx ! -1) { dirx 1; diry 0; } } else if (c 27) { over true; } } } void step() { for (int i len - 1; i 0; i--) { // 身体逐节前移 sx[i] sx[i - 1]; sy[i] sy[i - 1]; } sx[0] dirx; sy[0] diry; if (sx[0] 0 || sx[0] W - 1 || sy[0] 0 || sy[0] H - 1) { over true; return; // 撞墙 } for (int i 1; i len; i) if (sx[0] sx[i] sy[0] sy[i]) { over true; return; } // 咬到自己 if (sx[0] fx sy[0] fy) { len; score 10; if (speed 60) speed - 4; newFood(); } } int main() { hideCursor(); init(); while (!over) { input(); step(); draw(); Sleep(speed); } gotoxy(0, H 1); cout 游戏结束最终得分 score 。按任意键退出。\n; _getch(); return 0; }蛇身用一个 600 个元素的坐标数组表示sx[0]、sy[0]是头。每次移动的整体逻辑是从尾巴往前遍历把每一节的位置复制成前一节的位置这样视觉上就是整条蛇往前爬了一格。这里有个细节是遍历必须从尾到头如果从头发起头的位置会先被覆盖后面每一节拿到的都是已经被改过的值蛇会瞬间缩成一团。吃东西变长的实现有点巧妙先整体移动再len。因为移动的时候下标len那个位置还没有被赋值它保留着上一轮的尾巴坐标len加一之后这个陈旧的坐标就自动变成了新的尾巴。等于白捡了一节身体不需要额外的处理。方向控制里我加了diry ! 1这样的限制防止玩家在向右移动时直接按左那样蛇头会瞬间撞上自己脖子判定死亡手感很糟糕。这是很多版本会漏掉的细节。newFood()里的重试循环也不能省。食物如果随机落在蛇身上玩家就永远吃不到它游戏会卡死。所以生成之后必须遍历蛇身确认撞了就重新生成。想调难度的话改speed的初始值和递减步长就行。150 毫秒一格对大多数人来说偏慢你可以直接设成 100递减步长设成 8 的话二三十个食物之后就会快到极限适合熟手。3.4 射击训练场光标定位与毫秒级计时这个游戏练的是反应速度和命中率统计技术上主要是光标定位和按键扫描。#include iostream #include conio.h #include windows.h #include cstdlib #include ctime using namespace std; const int W 40, H 18; int px, py, tx, ty; // 准星与目标 int shots, hits; clock_t t0; void gotoxy(int x, int y) { COORD p; p.X (SHORT)x; p.Y (SHORT)y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), p); } void hideCursor() { CONSOLE_CURSOR_INFO ci; GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); ci.bVisible FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); } void newTarget() { tx rand() % (W - 2) 1; ty rand() % (H - 2) 1; } void draw() { gotoxy(0, 0); for (int y 0; y H; y) { for (int x 0; x W; x) { char ch ; if (y 0 || y H - 1 || x 0 || x W - 1) ch #; if (x tx y ty) ch $; if (x px y py) ch ; cout ch; } cout \n; } double sec (double)(clock() - t0) / CLOCKS_PER_SEC; cout 时间 (int)sec /30 秒 射击 shots 命中 hits 命中率 (shots ? (int)(hits * 100.0 / shots) : 0) % flush; } int main() { srand((unsigned)time(0)); hideCursor(); px W / 2; py H / 2; shots hits 0; newTarget(); t0 clock(); while ((double)(clock() - t0) / CLOCKS_PER_SEC 30.0) { if (_kbhit()) { int c _getch(); if (c 224) { c _getch(); if (c 72 py 1) py--; else if (c 80 py H - 2) py; else if (c 75 px 1) px--; else if (c 77 px W - 2) px; } else if (c ) { shots; if (px tx py ty) { hits; newTarget(); } } else if (c 27) { break; } } draw(); Sleep(30); } gotoxy(0, H 1); int rate shots ? (int)(hits * 100.0 / shots) : 0; cout 训练结束。射击 shots 次命中 hits 次命中率 rate %。\n按任意键退出。\n; _getch(); return 0; }第一个要点是循环结构。这里是先检查按键、再重绘、最后 Sleep的顺序而不是像贪吃蛇那样_getch()阻塞等待。因为限时是硬性的如果卡在阻塞读键上玩家不操作时倒计时就不会推进30 秒能拖成无限长。第二个要点是边界钳制。准星的移动全都带了py 1、px W - 2这种条件保证它不会跑到边框上或者跑出屏幕。如果不加这个判断准星一到边上就会消失玩家还以为程序崩了。第三个要点是命中率的计算。hits * 100.0 / shots里那个100.0不能写成100否则是整数除法0.99 会被截成 0命中率永远显示 0 或者 100。这个坑几乎所有初学者都踩过一次记牢整数除法想要小数结果分子或分母至少得有一个浮点数。可调的地方很多把30.0改成60.0就是一分钟挑战Sleep(30)数值调小会更跟手但更吃 CPU想加难度可以让目标在你每次移动准星时随机换位置那时命中率会掉得很厉害正好练预判。3.5 迷宫探险递归回溯生成随机迷宫第五个游戏带点算法味用递归回溯生成一个每次都不同的迷宫。#include iostream #include conio.h #include windows.h #include cstdlib #include ctime using namespace std; const int N 12; // 逻辑格子数 const int S N * 2 1; // 字符图边长25 char m[S][S 1]; int px, py, steps; void hideCursor() { CONSOLE_CURSOR_INFO ci; GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); ci.bVisible FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), ci); } void gotoxy(int x, int y) { COORD p; p.X (SHORT)x; p.Y (SHORT)y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), p); } void carve(int x, int y) { int d[4][2] {{-2, 0}, {2, 0}, {0, -2}, {0, 2}}; for (int i 3; i 0; i--) { // 洗牌保证方向随机 int j rand() % (i 1); int t d[i][0]; d[i][0] d[j][0]; d[j][0] t; t d[i][1]; d[i][1] d[j][1]; d[j][1] t; } m[y][x] ; for (int k 0; k 4; k) { int nx x d[k][0]; int ny y d[k][1]; if (nx 0 nx S - 1 ny 0 ny S - 1 m[ny][nx] #) { m[y d[k][1] / 2][x d[k][0] / 2] ; // 打通中间的墙 carve(nx, ny); } } } void draw() { gotoxy(0, 0); for (int y 0; y S; y) { for (int x 0; x S; x) { char c m[y][x]; if (x px y py) c ; if (x S - 2 y S - 2) c E; cout c; } cout \n; } cout WASD 或方向键移动走到 E 通关。已走 steps 步。 flush; } int main() { srand((unsigned)time(0)); hideCursor(); for (int y 0; y S; y) for (int x 0; x S; x) m[y][x] #; for (int y 0; y S; y) m[y][S] \0; carve(1, 1); // 从左上角开始挖 px 1; py 1; steps 0; while (true) { draw(); int c _getch(); int nx px, ny py; if (c 224) { c _getch(); if (c 72) ny--; else if (c 80) ny; else if (c 75) nx--; else if (c 77) nx; } else if (c w || c W) ny--; else if (c s || c S) ny; else if (c a || c A) nx--; else if (c d || c D) nx; else if (c 27) break; if (nx 0 nx S ny 0 ny S m[ny][nx] ) { px nx; py ny; steps; } if (px S - 2 py S - 2) { draw(); cout 到达终点共走了 steps 步。\n按任意键退出。\n; _getch(); break; } } return 0; }生成思路是经典的递归回溯先假定整个网格全是墙从某个不能被 2 整除的奇坐标格出发把它挖通然后随机挑一个方向如果隔两格的那个格还是墙就把中间那面墙也挖掉再递归进去。因为每次只往没访问过的格子走递归自然会在死路处返回最终所有格子都被打通且任意两点之间路径唯一——这是这种生成方式的一个漂亮性质意味着它不会产生环也不会出现孤岛。尺寸参数N是最值得调的。N决定逻辑格子数最终的字符图边长是N * 2 1。N 12对应 25×25 的地图在标准控制台窗口里正好一屏放得下不用滚动。如果你把N调到 20地图会变成 41 行控制台要往下滚gotoxy重绘就会错位画面会一团糟。要用大地图得先把控制台窗口的高度调大或者在代码里用SetConsoleWindowInfo主动改窗口尺寸。另外注意递归深度。N 12时递归层数最多一百多层完全没问题。但如果有人把N调到 100 以上递归可能把栈压爆。那时候就该把递归改成用显式栈的迭代写法了这是后话。3.6 2048 简化版方向归并和死局判定最后一个游戏看起来最复杂其实核心只有一个函数把一行的四个数按方向归并。#include iostream #include conio.h #include cstdio #include cstdlib #include ctime using namespace std; int g[4][4]; int score; void addTile() { int ex[16], ey[16], n 0; for (int i 0; i 4; i) for (int j 0; j 4; j) if (g[i][j] 0) { ex[n] i; ey[n] j; n; } if (n 0) return; int k rand() % n; g[ex[k]][ey[k]] (rand() % 10 0) ? 4 : 2; // 10% 概率出 4 } void show() { system(cls); printf( 2048 得分 %d\n\n, score); for (int i 0; i 4; i) { printf( ------------------------\n ); for (int j 0; j 4; j) { if (g[i][j] 0) printf(| ); else printf(|%5d , g[i][j]); } printf(|\n); } printf( ------------------------\n\n); printf(方向键移动ESC 退出\n); } void slideLine(int a[4], int gain) { int t[4] {0, 0, 0, 0}, k 0; for (int i 0; i 4; i) if (a[i]) t[k] a[i]; // 去掉空格 for (int i 0; i 1 k; i) if (t[i] t[i 1]) { t[i] * 2; gain t[i]; t[i 1] 0; i; // 跳过被吃掉的那个 } int r[4] {0, 0, 0, 0}, p 0; for (int i 0; i 4; i) if (t[i]) r[p] t[i]; // 压到一侧 for (int i 0; i 4; i) a[i] r[i]; } bool moveDir(int dir) { bool changed false; int gain 0; for (int i 0; i 4; i) { int line[4]; for (int j 0; j 4; j) { int x, y; if (dir 0) { x i; y j; } else if (dir 1) { x i; y 3 - j; } else if (dir 2) { x j; y i; } else { x 3 - j; y i; } line[j] g[y][x]; } slideLine(line, gain); for (int j 0; j 4; j) { int x, y; if (dir 0) { x i; y j; } else if (dir 1) { x i; y 3 - j; } else if (dir 2) { x j; y i; } else { x 3 - j; y i; } if (g[y][x] ! line[j]) changed true; g[y][x] line[j]; } } score gain; return changed; } bool canMove() { for (int i 0; i 4; i) for (int j 0; j 4; j) { if (g[i][j] 0) return true; if (j 3 g[i][j] g[i][j 1]) return true; if (i 3 g[i][j] g[i 1][j]) return true; } return false; } int main() { srand((unsigned)time(0)); score 0; for (int i 0; i 4; i) for (int j 0; j 4; j) g[i][j] 0; addTile(); addTile(); while (true) { show(); if (!canMove()) { printf(无路可走了最终得分 %d\n, score); break; } int c _getch(); if (c 27) break; int dir -1; if (c 224) { c _getch(); if (c 72) dir 0; else if (c 80) dir 1; else if (c 75) dir 2; else if (c 77) dir 3; } if (dir 0 moveDir(dir)) addTile(); } printf(按任意键退出\n); _getch(); return 0; }真正干活的是slideLine。它把一行的非零元素先抽出来排紧然后从左往右扫遇到相邻两个相同就合并成一个双倍数、把后一个清零接着压实到一侧。合并时那个i很关键没有它2 2 2 2会被合并成8而不是4 4这是 2048 实现里最经典的错误之一。moveDir里那两段一模一样的坐标映射看着有点啰嗦但这是为了把取线和写回分开。为什么不能直接在原数组上操作因为四个方向的行列对应关系不一样直接改会互相干扰。用一个临时的line[4]做中转逻辑就干净了。那段公式我建议你自己在纸上画个 4×4 表格推一遍比看代码快。changed这个返回值也不能省。它是用来判断这次滑动到底动了没有——如果玩家往左按但所有方块已经贴在最左边且无法合并那么不应该刷新新方块。少了这个判断玩家就能靠无效按键无限刷方块游戏直接失去平衡。canMove()负责死局判定只要还有空格或者任意相邻两格数值相同就还能动。这个判断要放在添加新方块之后、等待输入之前才能保证及时结束。4. 编译报错与运行异常排查速查4.1 高频报错对照表这几个代码在 Dev-C 里常见的报错我把它们整理成一张表遇到问题先对着查报错信息原因解决getch was not declared in this scope当前编译器的 conio.h 用的是带下划线版本把getch改成_getchkbhit改成_kbhitSleep was not declared没包含 windows.h补上#include windows.hCOORD does not name a type同上windows.h 缺失或大小写写错检查头文件COORD必须全大写clock was not declared没包含 ctime补上#include ctimerand was not declared没包含 cstdlib补上#include cstdlibundefined reference to SetConsoleCursorPosition没链接内核库或者编译器类型不对确认工程类型是 Console Application[Error] ld returned 1 exit status上一个程序的黑框还开着文件被占用关掉所有运行中的控制台窗口再编译中文全变成问号或方块源文件编码被存成了 UTF-8文件另存为 ANSI或者把提示文字改英文那张表里最后一条最容易被忽略。Dev-C 默认 ANSI 编码你从浏览器复制代码粘进去如果之前用别的编辑器打开过这个文件并保存成 UTF-8字符串就会乱。判断方法很简单编译能过、程序能跑但输出是乱码那就一定是编码问题不是代码问题。ld returned 1 exit status也特别常见。它几乎总是因为上一次运行的程序窗口没关可执行文件被系统占用链接器写不进去。养成习惯改完代码先关黑框再按 F11。4.2 画面闪烁、按键粘滞、蛇自己撞自己这三个是实时游戏里最典型的手感问题各自有不同成因。画面闪烁主要来自system(cls)和逐字符输出。解决办法就是前面说的用gotoxy(0, 0)覆盖重绘先把整帧内容拼进字符缓冲区再一次性输出。贪吃蛇如果还是用cls写法把蛇速调到 60 毫秒之后你会看到整个屏幕像在抽搐。按键粘滞指的是你松开键之后蛇还在继续转向。原因通常是方向判断的写法有问题——很多人把方向判断放在while(_kbhit())外面导致一帧只能读到一次按键剩下的积压在缓冲区里慢慢消费。正确写法是循环取空缓冲区。蛇自己撞自己初学者版往往是一按反向键立刻死。这就是我前面强调的方向更新必须带上当前方向的反方向不生效这个限制。加两行判断就能解决但不知道这件事的话会调试很久还以为是碰撞检测写错了。4.3 抄代码时最容易出错的三个地方第一个是数组下标和尺寸。贪吃蛇的蛇身数组我给了 600 个元素实际能长到的长度受画面大小限制够用。但如果你把画面改大、速度调慢蛇可能长过 600 节那就是数组越界程序会莫名崩溃或者画出奇怪的东西。改画面尺寸时记得同步调整数组大小。第二个是%取模的边界。rand() % (W - 2) 1这个表达式W - 2是可用区域宽度1是把结果挪到从 1 开始刚好避开边框。少一个括号、少一个加一食物就会生成在边框上甚至墙外面玩家永远吃不到。改尺寸时务必把这两个数字一起检查。第三个是整数除法。命中率、平均步数这类看起来该有小数的地方都很容易写成整数除法。记住那条规则只要结果可能小于 1就得保证参与运算的数里至少有一个是浮点。hits * 100.0 / shots里的100.0就是干这个用的。5. 手感调优与后续扩展5.1 把难度做成参数别写死在代码里这六个代码里所有能影响难度的数值我都尽量提成了变量或者常量贪吃蛇的speed和递减步长、射击游戏的30.0秒和Sleep(30)、迷宫的N、2048 里出 4 的概率。这么写的好处是调参不用改逻辑改一个数就能感受到明显差异。我的建议是把这些数值放在文件最上面和坐标常量排在一起用一个块注释标出来/* 可调参数 */ const int W 32; // 画面宽 const int H 20; // 画面高 const int INIT_SPEED 150; // 初始每格毫秒数 const int MIN_SPEED 60; // 最快速度上限 /* */更进一步的做法是把难度定义成枚举然后写一个applyDifficulty(int level)函数一次性设定所有参数。这样你想加个地狱模式只需要加一个 case不用满世界找数字改。这个小改动在写第二个游戏的时候就会体现价值。调参有个经验值可以参考贪吃蛇的移动间隔别低于 80 毫秒低于这个数大部分人的反应跟不上玩起来像在惩罚自己射击游戏的刷新间隔在 30 到 50 毫秒之间最舒服再快就是浪费 CPU。调试阶段可以在画面角落加一行实时的帧率或者耗时显示跑一会儿心里就有数了。5.2 从字符界面往上走的几条路这六个程序跑通之后如果你想继续往前走有三条路可以选投入产出比各不相同。第一条是继续深挖控制台。给贪吃蛇加上障碍物、加速道具、最高分记录用fstream存到本地文件给井字棋加一个不会输的电脑对手极小极大搜索这是学算法的好入口给迷宫加一个自动寻路的展示模式广度优先搜索用队列实现一步步把路径画出来。这些都还是在控制台里做不用碰任何新库但算法含量上了一个台阶。第二条是换图形库。EasyX 是最平缓的选择它的接口风格和conio.h很像circle()、fillrectangle()、getmessage()这些函数看一眼就会用。把贪吃蛇的字符改成方块、把射击的目标改成圆形、加上鼠标点击的枪口和光效一两天就能出个像样的作品。代价是要额外配置库而且 EasyX 只支持特定版本的编译器配环境那一步可能会耗掉一整个下午。第三条是走跨平台路线学 SDL 或者 SFML。这条路更正规也更费时间但学到的东西通用性强——音效、纹理、事件循环、坐标系变换这些概念换到任何游戏引擎里都是通的。如果你打算把游戏开发当成长期方向早点接触这类库比在控制台里反复打磨更有价值。我个人在教别人的时候有个观察直接上手图形库的人往往卡在环境配好了但游戏逻辑写不出来先把控制台这几个跑一遍的人换到图形库之后写逻辑的时间会短很多因为他已经知道游戏循环该长什么样、输入该怎么处理、状态该怎么维护了。所以别小看这几个黑框里跑的小玩意它们练的正好是最不容易速成的那部分。还有一个没什么技术含量但很实用的小建议每写完一个游戏用纸笔或者注释列一下这个游戏的状态有哪些。贪吃蛇的状态是蛇身坐标、方向、食物位置、分数、速度2048 的状态就是一个 4×4 数组加一个分数。你会发现所有游戏本质上都是一组状态 一个每帧执行的更新规则 一个把这些状态画出来的渲染函数。把这个套路想清楚再看任何游戏代码都会觉得清晰很多这比记住某个具体函数的名字有用得多。
返回列表