ARTICLE DETAIL

资讯详情

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

C++控制台小游戏实战:贪吃蛇、扫雷、2048从环境配置到完整实现

C++控制台小游戏实战:贪吃蛇、扫雷、2048从环境配置到完整实现 贪吃蛇能玩不代表你学会了C但只会背语法铁定写不出贪吃蛇。我见过太多人把C当理论课啃结构体、指针、虚函数背得滚瓜烂熟一让他动手写个C小游戏就卡壳。反过来那些舍得拿控制台折腾经典小游戏的人反而在语法细节上记得更牢因为每个知识点都是自己踩坑踩出来的。这篇东西不扯大道理直接把C各种经典小游戏的实现思路、关键代码和排坑经验摊开讲覆盖从环境配置到贪吃蛇、扫雷、2048三款经典游戏的完整落地过程适合刚学完C语法不知道该写什么练手的人也适合想和学生一起做项目但缺一套成熟方案的老师。1. 控制台游戏才是C新手最该写的项目很多初学者对“游戏”两个字有误解觉得一定要上Unity、Unreal或者至少用个EasyX图形库做一个带贴图、带音效的东西才算游戏。这个观念要纠正一下——初学者写C游戏价值最大的恰恰是看起来最土的Win32控制台程序。控制台游戏的优势在于它把C语法的各个知识点全部串起来了。写一个真正能玩的贪吃蛇你必须处理输入输出、循环控制、数组或结构体、函数封装、随机数生成、时间延迟、状态判断。写扫雷你要碰二维数组、边界判断、递归算法、逻辑运算。写2048你自然要琢磨矩阵操作和函数抽象。这套组合拳打下来你学过的语法几乎没有用不上的。而且控制台游戏天然带反馈。你写一个类、一个函数对不对跑一遍就知道。游戏崩了就是代码有bug画面卡住就是逻辑死循环这种即时反馈比做一百道填空题都有效。C最大的学习障碍是“学了不会用”控制台游戏恰好解决了“用在哪”的问题。我整理了一张覆盖对照表按C小游戏的常用需求对应到相关知识点你可以感受一下游戏需求涉及C知识点地图渲染二维数组、循环输出、ASCII字符编码物体移动状态变量、增量计算、坐标变换键盘响应输入流、系统API调用conio.h/windows.h吃食物/得分随机数、结构体、作用域与生命周期碰撞判定条件分支、遍历比较、边界检查游戏循环while/for循环、Sleep延时、逻辑帧设计再往深处讲控制台游戏逼着你理解程序运行的本质。你写一个图形界面程序很多逻辑被框架帮你隐藏了但在控制台项目里每一帧画面怎么刷新、用户按键什么时候轮询、死循环什么时候退出全都得自己操心。这种对程序执行流程的控制感是后面学任何框架都需要的底层能力。2. 先把环境搞利索Windows下VS Code配MinGW-W64的完整姿势教程一上来就写代码是耍流氓环境配不明白能劝退一半人。我默认你用的是Windows系统IDE选择Visual Studio Code加MinGW-W64编译器的组合。为什么不推荐Visual Studio本体因为它太臃肿新建一个控制台项目要等半天对只需要编译单文件的练习来说VS Code这种轻量编辑器的体验好得多。MinGW是Windows上的GCC编译器去官方镜像站下载MinGW-W64的时候要注意选中x86_64架构和posix线程模型的版本。装的时候建议放在纯英文路径下比如C:\mingw64装完之后把C:\mingw64\bin加进系统环境变量Path。这一步做完在终端里输入g --version能看到版本信息就算通了。VS Code这边要装三个扩展C/C微软官方那个、Code Runner、以及可选的C/C Extension Pack。装完扩展你需要手动写两个配置文件一个管编译一个管调试。我直接给你一套能用的配置。tasks.json放在.vscode目录下负责定义怎么把源码编译成exe文件{ version: 2.0.0, tasks: [ { label: build, type: cppbuild, command: g, args: [ -stdc17, -Wall, -fexec-charsetUTF-8, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: build, problemMatcher: [$gcc] } ] }launch.json负责调试时的运行参数{ version: 0.2.0, configurations: [ { name: C Debug, type: cppdbg, request: launch, program: ${fileDirname}/${fileBasenameNoExtension}.exe, stopAtEntry: false, cwd: ${workspaceFolder}, externalConsole: true, MIMode: gdb, miDebuggerPath: C:/mingw64/bin/gdb.exe, preLaunchTask: build } ] }这套配置有两个很多人不知道的细节。一是externalConsole要设成true这样调试时程序会在独立窗口运行控制台小游戏的键盘输入才能正常处理如果设成false终端都内嵌在VS Code里_kbhit这类函数的行为会很奇怪。二是-fexec-charsetUTF-8这个参数特别重要。不加它你在VS Code里用UTF-8编码写的cout 中文在Windows控制台上大概率乱码。实际编译运行的时候还有三个坑得提前排掉。第一个坑文件路径不能有中文和空格。你建项目文件夹叫“C贪吃蛇”g可能直接报错或者生成不了exe。养成好习惯所有项目路径用英文命名。第二个坑控制台窗口闪退。程序跑完一瞬间窗口就关了什么都看不清。在main函数返回之前加一行system(pause)从Windows系统层面停住窗口。第三个坑用到了Sleep函数、system(cls)这类Windows API编译时不需要额外链接库但别忘了#include windows.h。用_kbhit和_getch要加#include conio.h这俩是头文件提供的控制台输入函数。这套环境配置好之后往后所有C小游戏项目都能直接编译不用重复折腾。3. 贪吃蛇用“数组模拟链表”管理一条会变长的蛇贪吃蛇是C小游戏里的Hello World但真把它写好还真不简单。它同时涉及坐标管理、方向控制、碰撞检测、随机食物和动态增长每个环节都有讲究。我的建议是第一版不要追求完美先用最简单粗暴的方式跑通再逐步优化。3.1 蛇身数据结构为什么用vector而不是链表初学者最容易想到的就是用链表因为蛇在吃东西时身体会变长这不就是链表动态加节点吗理论上没错但控制台贪吃蛇最多几十个节点链表的动态内存管理优势根本体现不出来反而让代码复杂不少。我用std::vectorPoint存蛇身每个Point是一个坐标结构体头部在front()尾部在back()。#include iostream #include vector #include conio.h #include cstdlib #include ctime #include windows.h struct Point { int x, y; }; enum Direction { UP, DOWN, LEFT, RIGHT };移动的逻辑核心是“头部插新尾部看情况删”。蛇每走一步在头部的方向坐标上生成一个新头insert进去如果这一格是食物保留尾部蛇就变长了如果不是食物pop_back()删掉尾部蛇身长度保持不变。这套逻辑用数组、链表还是vector都能实现但vector最简洁还方便遍历判断有没有撞到自己。// 移动核心 Point newHead snake.front(); switch(dir) { case UP: newHead.y--; break; case DOWN: newHead.y; break; case LEFT: newHead.x--; break; case RIGHT: newHead.x; break; } snake.insert(snake.begin(), newHead); if (newHead.x food.x newHead.y food.y) { score 10; spawnFood(); // 重新生成食物 } else { snake.pop_back(); }3.2 方向锁一个几乎人人都踩的经典bug你第一次写完贪吃蛇大概率会遇到这种情况蛇正在向左走你快速按了上再按右蛇直接穿过了自己的身体游戏没判死但画面上蛇像断了节一样错位。这就是典型的“同一帧内两次转向导致的反向移动”bug。解决方案是加一个方向锁。蛇的移动和按键读取是分离的每次移动结束后记录当前方向按键处理只更新期望方向但有一个限制条件——不可以反向。比如当前方向是右你按了左这个指令直接丢弃但如果你按了上哪怕立刻按右程序在下一帧才会更新方向实际上蛇是先向上挪了一格再向右这样就不会出现穿越。if (_kbhit()) { int key _getch(); // 方向键在Windows控制台是双字节第一个字节通常是224或0 if (key 224) key _getch(); switch(key) { case 72: if (dir ! DOWN) dir UP; break; case 80: if (dir ! UP) dir DOWN; break; case 75: if (dir ! RIGHT) dir LEFT; break; case 77: if (dir ! LEFT) dir RIGHT; break; } }3.3 刷新重绘为什么你的蛇有“尾巴影”控制台没有现成的图形刷新机制你每帧都要system(cls)清屏然后按坐标重新绘制整个地图。这里有个新手很容易犯的错——只清屏但不清缓冲区或者绘制顺序反了导致画面有残留的字符。正确的顺序是先清屏再绘制边界再绘制食物再遍历蛇身绘制最后输出分数。每帧绘制完成后用Sleep(100)控制速度100毫秒一帧是起步值。实测下来低于50毫秒蛇跑得太快人眼根本反应不过来高于200毫秒又太肉。system(cls); // 绘制地图和蛇 for (int y 0; y HEIGHT; y) { for (int x 0; x WIDTH; x) { bool isBody false; for (auto p : snake) if (p.x x p.y y) { isBody true; break; } if (x food.x y food.y) std::cout *; else if (isBody) std::cout O; else if (x 0 || x WIDTH - 1 || y 0 || y HEIGHT - 1) std::cout #; else std::cout ; } std::cout \n; } std::cout Score: score \n; Sleep(100);游戏结束的判断条件有三个撞墙、撞自己、蛇长到占满整个地图。第一版本地判断一下前两个就够了第三个条件属于进阶优化。贪吃蛇这个项目最值钱的地方在于它让你第一次用程序手段管理一个“实时变化的状态集合”。蛇身是不断变化的食物是不断重生的方向是动态切换的这比写一个线性执行的计算器项目复杂得多也接近真实游戏项目的感觉。4. 扫雷二维数组、偏移数组和递归泛洪的结合扫雷比贪吃蛇高一个层级。贪吃蛇的难点在状态管理扫雷的技术重点则在二维数据处理和递归算法。这个游戏的经典程度不用多说Windows扫雷承载了太多人的童年回忆用C把它实现一遍你会对二维数组有完全不同的理解。4.1 棋盘建模两张二维表搞定一切扫雷的核心是两张二维表。一张存地雷分布和数字信息用整型数组int mine[ROW][COL]一张存给玩家看到的显示状态用字符数组char show[ROW][COL]。const int ROW 10, COL 10, MINE_COUNT 15; int mine[ROW][COL]; char show[ROW][COL];为什么用两张表而不是一张因为游戏有两个层面的信息真相和玩家视角。玩家看到的是show表里面有未翻开、已翻开数字或空格、已标记地雷F三种状态而真正的雷和数字藏在mine表里。翻格子时以mine表的值为依据更新show表。这个“真相与视角分离”的设计思想以后写任何带信息隐藏的业务逻辑都用得上。4.2 布雷算法怎么避免随机数重复布雷是第一个细节。用rand() % ROW加rand() % COL随机选址很可能会选到同一个格子导致最终地雷数量不对。最简单的解决办法是while循环判断重复就重新生成int placed 0; while (placed MINE_COUNT) { int r rand() % ROW; int c rand() % COL; if (mine[r][c] ! -1) { mine[r][c] -1; placed; } }但这个方法在雷数极少时没问题雷数接近格子总数时可能变成死循环概率极低但理论上存在。更优雅的方案是生成一个包含所有索引的一维数组然后用std::shuffle洗牌取前MINE_COUNT个作为雷位。这个思路本质上和洗牌算法是一致的值得掌握。布雷之后是计算每个非雷格子的数字。这里有个非常关键的技巧——遍历8个邻格时不要手写8个if而是用一个偏移数组int dir[8][2] {{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}}; for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (mine[i][j] -1) continue; int cnt 0; for (int k 0; k 8; k) { int ni i dir[k][0]; int nj j dir[k][1]; if (ni 0 ni ROW nj 0 nj COL mine[ni][nj] -1) cnt; } mine[i][j] cnt; } }偏移数组在游戏开发里太常用了。寻路、地图遍历、碰撞检测凡是涉及“按方向探测周围格子”的场景都可以用偏移数组替代好几层嵌套的if逻辑。4.3 递归泛洪点到空白区域一键展开的核心扫雷最好玩的地方就是点一个空白格子周围一大片空白跟着打开。这个现象的学名叫泛洪填充Flood Fill实现起来就是递归或栈。void reveal(int r, int c) { if (r 0 || r ROW || c 0 || c COL) return; if (show[r][c] ! ?) return; // 已翻开或已标记 if (mine[r][c] -1) return; // 是地雷 if (mine[r][c] 0) { show[r][c] 0 mine[r][c]; // 边界格子只显示数字 return; } show[r][c] ; // 空白格子翻开 for (int k 0; k 8; k) { int ni r dir[k][0]; int nj c dir[k][1]; if (ni 0 ni ROW nj 0 nj COL) { reveal(ni, nj); } } }这个递归函数的终止条件写得很讲究。四条return分别处理越界、重复翻开、踩雷、以及遇到数字边界。没有这四条边界控制递归会一直往8个方向扩散最终把整个棋盘都翻过来或者无限递归直接栈溢出。带孩子写扫雷的时候我最喜欢让他观察一个现象点边缘数字格子和点中心空白格子的展开效果完全不一样。点中心空白格回触发一大片区域展开这就是递归的魅力所在——每一层递归向四周扩散直到碰见数字边界才停下来。4.4 输赢判定最后的临门一脚踩到地雷时翻开所有地雷游戏结束所有非雷格子都翻开游戏胜利。bool checkWin() { int opened 0; for (int i 0; i ROW; i) for (int j 0; j COL; j) if (show[i][j] ! ?) opened; return opened ROW * COL - MINE_COUNT; }扫雷项目做下来最大的收获不是算法而是一种严谨的边界意识。每一处下标运算都要想清楚会不会越界每一条游戏规则都要在代码里找到对应实现。这种思维方式对以后写任何大型程序都是根本性的。5. 2048一个方向函数打天下的矩阵旋转技巧2048这款游戏在C小游戏里属于颜值和智商双重担当。玩法不复杂4x4棋盘每次滑动所有数字向对应方向移动相同数字碰撞时合并。难点在于四个方向的滑动逻辑怎么写——如果每个方向写一套代码会非常臃肿而且特别容易出bug。5.1 核心洞察旋转矩阵复用同一个移动函数新手下笔大概率会按上下左右四个方向分别写移动函数。但你仔细观察会发现2048的移动规则在四个方向上完全对称。向左移动时每一行的数字向左靠拢相同数字合并向右移动只不过是把方向反过来。对称性意味着我们可以只写一个左移函数然后通过旋转棋盘来复用。具体来说想让数字往上移动就先把棋盘顺时针旋转90度执行一次左移再旋转270度转回来。这个思路看似多做了几步但换来的是代码量减少四分之三而且只有一套核心逻辑需要调试。// 顺时针旋转90度 void rotate(int board[4][4]) { // 1. 转置 for (int i 0; i 4; i) for (int j i 1; j 4; j) std::swap(board[i][j], board[j][i]); // 2. 水平翻转 for (int i 0; i 4; i) for (int j 0; j 2; j) std::swap(board[i][j], board[i][3 - j]); }旋转这个技巧本身就是编程思维里“抽象与复用”的典型案例。处理复杂问题时不要一上来就拆成4个可能相似的子问题先思考能不能找到一个公共解法再用变换去适配不同场景。5.2 左移函数的三个步骤去零、合并、再去零左移的核心算法分成三步走。第一步是去零。每一行扫描一遍把非零数字按顺序提取到一个临时数组后面补零。这个操作本质是压缩密度。int row[4] {0}; int idx 0; for (int j 0; j 4; j) if (board[i][j] ! 0) row[idx] board[i][j];第二步是合并。从最左边开始如果相邻两个数字相等左边的乘2右边的置零。这里有一个注意点每一行在一次滑动中同一个数字只能参与一次合并。比如“2 2 2 2”往左滑正确结果是“4 4 0 0”而不是“8 0 0 0”。所以合并逻辑要用一个单独的循环从左往右扫。for (int j 0; j 3; j) { if (row[j] row[j1] row[j] ! 0) { row[j] * 2; row[j1] 0; } }第三步是再次去零。合并之后数组里出现了新空位再执行一次第一步的逻辑把数字重新左对齐。int newRow[4] {0}; idx 0; for (int j 0; j 4; j) if (row[j] ! 0) newRow[idx] row[j];完整代码里还有一个容易忽略的细节一个“move”函数必须返回一个bool值告诉主循环本次滑动是否让棋盘发生了变化。玩家按了一个方向键如果棋盘没有任何数字移动那么就视为无效操作不生成新数字回合也不推进。这个细节决定了游戏体验是否严谨不少线上Demo就栽在这——按了方向键明明没动还往棋盘塞新数字。5.3 游戏结束判断两遍扫描定胜负2048的胜负判定比扫雷简单玩家凑出2048就胜利也可以选择继续玩棋盘满了并且无法再合并任何数字游戏结束。判断“能否再合并”只需要两次扫描。第一次扫描所有格子只要有一个空位游戏就继续。第二次扫描所有相邻对——横向看board[i][j] board[i][j1]纵向看board[i][j] board[i1][j]只要有一对相等就还有合并的可能。bool canMove(int board[4][4]) { for (int i 0; i 4; i) for (int j 0; j 4; j) if (board[i][j] 0) return true; for (int i 0; i 4; i) for (int j 0; j 3; j) if (board[i][j] board[i][j1]) return true; for (int i 0; i 3; i) for (int j 0; j 4; j) if (board[i][j] board[i1][j]) return true; return false; }新数字生成也很讲究。在有效的空位上随机生成290%概率或410%概率这已经是这个游戏不成文的惯例了数值手感经过玩家大量验证直接照抄即可。2048的精华在于它要求你从机械的“逐个方向实现”中跳出来寻找问题背后的对称性。你是不是养成了从更高维度抽象问题的习惯写一遍这个游戏自己心里就有数了。6. 调试、重构、继续深入三款游戏写完之后的路写完三款游戏你已经有了一堆可以运行的代码。但这个阶段最重要的不是继续写第四款游戏而是回头打磨已有的代码把烂代码整理成能进简历的样子。6.1 控制台游戏最常见的三类bug我这几年带人写C小游戏发现控制台项目的bug高度集中提前了解能省下大量时间。第一类bug是输入状态残留。_getch()读取按键时会反映射到方向键的双字节在有些编译器上方向键第一字节是224在另一些平台上可能是0。处理方式就是判断一下首字节是224还是0再读第二字节。这个坑不填换台机器代码就失灵。第二类bug是乱码问题。Windows控制台默认用GBK编码VS Code默认写UTF-8两者不对齐就是满屏乱码。解决方案有三个层面代码里用system(chcp 65001)切到UTF-8代码页或者编译时加-fexec-charset参数最稳妥的是一开始界面输出全用英文把乱码问题根治掉。第三类bug是刷新闪烁。system(cls)清屏再重绘在Sleep较短的时候会看到明显的一闪一闪。解决方案是用Windows API的光标定位函数每次把光标挪到(0,0)而不是清屏这样画面几乎不闪。具体到代码先用GetStdHandle(STD_OUTPUT_HANDLE)拿到控制台句柄再用SetConsoleCursorPosition移动光标熟练之后可以自己去查一下这个API。6.2 从面向过程走向面向对象写控制台游戏最容易暴露的一个问题是全局变量满天飞。贪吃蛇时代你可能是几个全局数组打天下游戏规模一大就不行了函数之间靠全局变量传数据改一个牵一发动全身。我强烈建议把三个游戏都改写成类。贪吃蛇的SnakeGame类里包好地图宽度、蛇身、当前方向、食物、分数、游戏是否结束扫雷定义一个Minesweeper类构造函数传行列数和雷数初始化棋盘2048更简单Game2048类持有二维矩阵和操作它的函数。这段重构做下来你对C访问控制、封装、成员函数的意义会有质的理解。我当时带小学员做这步重构的时候最直观的转变是原来一个main函数几百行重构之后每个方法都小而清晰名字看一眼就知道是干嘛的。这种代码组织能力写多少书都学不来只能靠改自己的烂代码练出来。6.3 从控制台走向真正带图形的游戏控制台小游戏练的是逻辑和语法但真实游戏开发哪有控制台呢所以写到一定程度你肯定会想往图形化方向走。可选的方向有这么几个。EasyX图形库是给C初学者设计的Windows图形库不用学复杂框架看看文档就能画出图形界面。想学通用游戏开发SDL2是跨平台多媒体库体积小上手快。再往上理解游戏循环、碰撞检测、贴图渲染那就可以摸摸Unity了——但Unity的脚本语言是C#和C的关系在于逻辑思维相通。我个人的建议是先不要急着换语言和框架。把三款小游戏用EasyX重新画一遍界面把键盘输入从_getch换成消息循环体会一下即时渲染的逻辑差异。这个过程依然发生在C小游戏的范畴内但编程体验已经是完全不同的境界了。图形界面的游戏你需要掌握事件驱动模型、资源管理、渲染管线这些概念。有了控制台游戏打底理解这些抽象概念会顺利得多。最后一个实用性建议把三个游戏工程分目录放好每个目录配一个README.md写上编译方式和玩法说明。半年后你回头看自己写的东西或者拿着这些项目去面试、去参加比赛你会感谢自己当初认真整理了代码。C的学习曲线确实陡但靠一个个能跑起来的小游戏堆出来的底子是最不容易忘的。
返回列表