用C++写小游戏这事儿,我一直觉得是学语言最划算的练法之一。语法学完、指针和类也啃了、OJ题刷了几十道,但真到让你单独做一个能玩的东西时,你才会发现自己其实什么都不会。网上说“C++经典小游戏”的资料一抓一大把,但大部分就是甩给你一堆源码,让你自己抄,抄完也不知道为什么这么写。这篇东西我打算按自己带新人的路子,把C++做控制台小游戏这件事完整拆一遍:环境怎么搭、主循环怎么写、贪吃蛇扫雷这些经典案例的核心难点在哪,还有我这些年踩过的坑和排错思路。你跟着走一遍,不仅能跑出几个游戏,更重要的是能搞明白“一个能玩的程序”到底是靠哪些逻辑撑起来的。
适合看这篇的,基本有三类人:一类是刚学完C++基础、想找项目练手的学生;一类是学校课程设计想做个游戏交差但不想纯抄代码的同学;还有一类就是纯粹好奇“控制台里怎么也能做游戏”的爱好者。不管你属于哪种,只要能把环境跑通、把代码敲一遍,收获会比刷十遍语法书都大。
1. 为什么非得用C++写小游戏
1.1 C++写小游戏能练到什么东西
很多初学者有个误区,觉得小游戏用Python、网页不香吗,非要碰C++这个老古董。这话对了一半:你要是只想“快速做出一个能玩的东西”,Python确实快,网页端更是写起来爽。但换一个角度,C++控制台游戏恰好把编程里最难啃的几块骨头全练到了,而且是在一个你能看得见、玩得着的项目里练。
第一,语法基础全部激活。写游戏离不开数组、循环、分支、函数,稍微复杂一点还会用到结构体、指针、STL容器。比如贪吃蛇的蛇身你拿vector存,扫雷的地图你用二维数组铺,2048的合并逻辑你得拼命调数组下标。平时刷题用这些语法是“为了用而用”,写游戏时你是在解决真实需求,脑子里被迫去理解内存里数据到底怎么摆的。
第二,调试能力会突飞猛进。小游戏是长在Bug上的。蛇走着走着穿墙了,扫雷数字不对,游戏一卡一卡……这些Bug不会像OJ题那样给你报个“答案错误”,而是程序正常运行但行为诡异,你得一点点排查。我见过很多学生,代码写得飞快,一遇到Bug就懵。C++小游戏正好训练你“根据现象反推原因”的能力,这种能力到真实项目里太值钱了。
第三,能建立起“程序是怎么跑起来”的整体认知。控制台游戏里,你需要手动管理一个循环:接收输入,更新数据,绘制画面。这个循环放大一万倍,就是游戏引擎、GUI框架、服务端主循环的底层模型。你写过一次后,再看什么图形库、框架,都会有“哦,这不就是把我手动做的事封装好了嘛”的感觉。
1.2 C++和Python、Java、网页小游戏怎么选
我在带人时经常被问:同样做小游戏,用C++和其他语言比优势在哪?这个问题你得分场景看。
Python写游戏的优势是快,库也多,pygame两三行就能搞出个窗口。但坏处也明显,游戏逻辑一复杂,性能就不太好看,而且Python把太多底层细节藏掉了,你学不到内存管理、指针、编译型语言那些硬核概念。Java的话,Swing写桌面游戏其实挺成熟的,跨平台也好,但语法啰嗦,环境也重,而且JVM那一套对新手理解“程序到底怎么访问硬件”帮助不大。网页端小游戏,比如用Canvas,优势是分享方便,但你已经不是在写C++了,方向偏到前端去了。
C++控制台游戏的核心优势在两方面:一是完全零依赖,一个编译器就能开工,不用装游戏引擎、不用配复杂库,所有代码敲出来的都是看得见的逻辑;二是性能好、贴近底层,吃什么内存你心里有数,这强迫你考虑数据结构和算法效率。像热搜里一直有人搜“C++小游戏编程代码”,确实这类代码网上最不缺,但缺的是能讲清楚“这段代码为什么这么写”的教程,这也正是这篇要补上的。
1.3 经典小游戏是新手最合适的练手对象
为什么是“经典”小游戏?因为贪吃蛇、扫雷、2048、俄罗斯方块这些游戏有个共同特点:规则极简、状态清晰、逻辑封闭。规则极简意味着你不需要花大量时间去理解需求,能把精力全放在代码设计上。状态清晰意味着你能用数组、变量很直观地表达游戏里的所有东西,不用搞复杂对象模型。逻辑封闭意味着游戏运行是独立的,不会牵扯到网络、多线程、持久化这些额外难点。
对于第一次做完整项目的人来说,这种“拎得清”的项目刚刚好。我也见过一上来就做大项目、想用C++写个RPG的结果一个月连菜单都没做出来的,那纯粹是给自己添堵。先把经典小游戏吃得透透的,再往复杂的东西走,路才顺。
2. 开发环境准备与第一个能跑的程序
2.1 编译器选型和VSCode配置
C++开发环境这事儿,劝退过一大批新手。光搜“vscode配置c/c++环境”就能翻出几十篇教程,但很多写得又长又乱,跟着走一半就出错。我这里只讲两套方案,你任选一套,别纠结。
方案一:Windows下用VSCode + MinGW-w64。这是大多数学生和业余爱好者的主力搭配。MinGW-w64是Windows上的GCC编译器,体积小,装完就能在命令行里用g++编译。选它当编译器,是因为它免费、轻量、错误提示标准,和Linux上的GCC一脉相承,以后换Linux也不会觉得陌生。
装MinGW-w64有个坑要提醒:别去官网找那种旧版本的安装器,容易装半天还装不上。直接去GitHub找w64devkit或者用msys2的工具链,下载解压后把bin目录加进系统的PATH环境变量就行。装完在任意终端里执行g++ --version,能输出版本号就说明成了。VSCode这边装两个扩展:C/C++(微软官方出的那个,带智能提示和调试)和Code Runner(用于快速运行单个文件)。配置tasks.json和launch.json网上模板非常多,我跟新手说的核心就一句:只要g++能编译成功,运行调试都是锦上添花,先会命令行编译再说。
方案二:直接用Visual Studio。如果你不嫌它体积大,装个Community版,新建“控制台应用”项目直接写代码,按F5就能跑能调试。VS的调试器对新手非常友好,断点、变量监视都比VSCode直观。缺点是太重,而且它默认的MSVC编译器和MinGW在语法细节上有一丁点差异,个别代码在VS上能跑,用MinGW报错,反之也有。
我个人的建议是:如果你只是学着玩玩,用VSCode;如果电脑配置好、不介意装个大软件,Visual Studio会让你少受很多“环境跑不通”的气。
2.2 经典主循环:输入-更新-渲染
控制台游戏的结构说白了就一句话:一个while循环,反复做三件事——处理输入、更新状态、重新绘制。这个结构几乎所有游戏都一样,区别只在更新里你写的是什么逻辑。
先看一个最基础的骨架:
#include <iostream> #include <conio.h> #include <windows.h> bool gameOver = false; // 这三个函数后面逐个实现 void processInput(); void update(); void render(); int main() { // 初始化游戏状态 init(); while (!gameOver) { processInput(); // 读取键盘输入 update(); // 更新游戏状态(蛇移动、方块下落等) render(); // 把状态画到屏幕上 Sleep(50); // 控制帧率,避免跑太快 } system("pause"); return 0; }这段代码里,Sleep(50)是控制节奏的关键。很多人第一次写游戏时没加这个,游戏直接快得没法玩,因为循环跑得比CPU还快。Sleep(50)的意思是每帧暂停50毫秒,一秒钟大概20帧,对控制台游戏来说足够流畅。需要让游戏变快或者变慢时,改这个数字就行。
processInput里一般用_kbhit()判断键盘有没有按下,再用_getch()读取具体按键。_kbhit是非阻塞的,没按键时返回0,有按键时返回非0,这样游戏不会卡在“等你按键”,而是一直在跑。_getch()读取一个字符,方向键会返回两个字节,第一个字节是224(Windows下),遇到这种情况得特殊处理。
这个主循环你一定要看得滚瓜烂熟,后面所有游戏都是在这个框架上加东西。
2.3 控制台窗口基础操作:光标隐藏、定位、配色
写控制台游戏,你默认的操作方式是cout一行行往下打印。但游戏画面需要反复刷新,你不能每帧都从头打到底,那样屏幕会闪得睁不开眼。这时候就需要直接操作控制台的光标位置和颜色。
Windows提供了SetConsoleCursorPosition和SetConsoleTextAttribute这些API,但直接调用比较啰嗦,通常封装成gotoxy和color两个函数:
#include <windows.h> void gotoxy(int x, int y) { COORD coord; coord.X = x; coord.Y = y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); } void setColor(int color) { SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), color); } void hideCursor() { CONSOLE_CURSOR_INFO cursor; cursor.dwSize = 100; cursor.bVisible = FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cursor); }这三个函数是我写的所有控制台游戏的地基。gotoxy让光标跳到指定行列,这样你能随时覆盖画面上的某一块,而不是整屏重绘。setColor用来给文字上色,数字1到15对应不同颜色,比如7是默认灰白,12是亮红,14是亮黄。hideCursor隐藏闪烁的光标,不然画面上一个光标闪来闪去很烦。
使用示例:想在第3行第5列用红色打印“Game Over”,就写:
gotoxy(5, 3); setColor(12); std::cout << "Game Over";这套API是Windows独有的,Linux/macOS上没法直接用。这也是为什么控制台小游戏教程绝大多数都是给Windows准备的。你要是在别的平台,得用curses库替代,但思路完全一致,不冲突。
2.4 工程结构建议:别把所有代码塞进一个main.cpp
带过的学生里十个有九个喜欢把几百行代码全堆在main.cpp里,跑是能跑,但改起来特别痛苦。我建议一开始就分文件:
main.cpp:放主函数和主循环。game.h/game.cpp:放游戏状态变量和核心逻辑(更新、碰撞等)。render.h/render.cpp:放所有界面绘制相关代码。input.h/input.cpp:放输入处理。
这个分层的好处是:当你改绘制逻辑时,不需要动游戏逻辑;当你想从贪吃蛇改成别的游戏时,只需要换掉game.cpp,外壳不用大改。虽然前期写结构会多花一点时间,但等你改Bug、加功能的时候就知道这个决定的香了。
有些IDE新建项目时自动生成一堆文件,不用全看懂,你只需要知道自己的代码该放哪个文件就行。编译的时候,用命令行或者VSCode的task,把main.cpp game.cpp render.cpp input.cpp一起编译,例如:
g++ main.cpp game.cpp render.cpp input.cpp -o game.exe多个.cpp文件编译时不能漏一个,漏了会出现“对xxx未定义的引用”这种链接错误,这是新手最容易遇到的报错之一。
3. 小游戏背后的核心算法与数据结构
3.1 用数组和变量表达游戏世界
控制台游戏没有图形画面,所谓“画面”就是屏幕上排列的字符。因此,你得找到一个数据结构去描述“哪个位置是什么东西”。最常用的就是二维数组。
以贪吃蛇为例,地图按行列分,map[i][j]可以表示第i行第j列的状态:0是空白、1是墙壁、2是蛇身、3是食物。每次移动,就是把这个数组里表示蛇尾的格子改成0、蛇头的格子改成2,然后重新按数组内容打印画面。数组的内容等于“游戏世界”,打印只是把世界“照出来”。
这个思路看着简单,却是整个游戏开发的地基。你后面做扫雷,需要mineMap存雷的位置、showMap存玩家看到的状态;做俄罗斯方块,需要一个固定大小的棋盘数组,方块落进去后对应格子标记为1。几乎所有经典小游戏都能用“一个或几个二维数组 + 若干状态变量”描述清楚。想通了这点,你看到任何游戏构思的第一反应就不是“这功能好难”,而是“这功能该用什么结构存”。
需要注意,数组下标从0开始,边界判断容易出错。比如一个20行40列的地图,合法行号是0到19,你判断“下一行是否越界”时,很多人写if (y + 1 >= 20),这没错,但写惯后容易把等号写漏,一漏就是穿墙的Bug。所以初始化时可以把地图最外圈全部设为墙,只在中间区域游戏,这样越界问题就自动被墙体挡住了。
3.2 状态机思维:游戏离不开状态切换
游戏里到处都是“状态”。菜单、游戏中、暂停、结束,是状态;贪吃蛇的上下左右,是方向状态;扫雷还没踩雷和已经踩雷,也是状态。管理好状态,游戏逻辑才不会乱成一锅粥。
最土但最实用的方法是枚举类型加一个全局变量:
enum GameState { MENU, PLAYING, PAUSED, GAME_OVER, WIN }; GameState state = MENU;主循环里根据state的值决定走哪套逻辑:
while (running) { switch (state) { case MENU: handleMenu(); break; case PLAYING: handlePlaying(); break; case PAUSED: handlePause(); break; case GAME_OVER: handleGameOver(); break; } }这个模式的好处是,每套逻辑之间互不干扰,你在游戏进行中按一下空格进入暂停,只需要state = PAUSED,所有按键响应都换一套。等你想加“胜利页面”或者“设置菜单”,只需要再往枚举里加值、加一个分支,主循环结构完全不用动。
跟我写东西的人常犯的错是:用一堆if变量去控流程,什么bool isPaused、bool isGameOver、int menuIndex混在一起,到后期一个按键改了三个变量,自己想都想不清楚。状态机的纪律性,能避免大量这种问题。
3.3 碰撞检测:游戏逻辑的核心难点
“碰撞检测”这个词听着高级,在控制台游戏里其实就是“判断两个东西是不是到了同一个位置”。比如贪吃蛇吃到食物:判断snakeHeadX == foodX && snakeHeadY == foodY。蛇撞到自己:每走一步看看新蛇头的位置是否和已有蛇身重叠。扫雷踩雷:翻开一个格子,看它是不是雷。
碰撞检测的关键在于,你要明确“碰撞发生前”和“碰撞发生后”的两次判断时机。以贪吃蛇为例,蛇每帧先根据方向计算一个新蛇头坐标,然后立刻检测这个坐标上是什么:
int newHeadX = snake[0].x + dx; int newHeadY = snake[0].y + dy; if (map[newHeadY][newHeadX] == WALL || isSnakeBody(newHeadX, newHeadY)) { gameOver = true; return; } if (map[newHeadY][newHeadX] == FOOD) { // 吃到食物,分数加一,不删尾巴,蛇身变长 score++; generateFood(); } else { // 正常移动,删掉蛇尾 map[snake.back().y][snake.back().x] = EMPTY; snake.pop_back(); } // 把新蛇头加到最前面并标记 snake.insert(snake.begin(), {newHeadX, newHeadY}); map[newHeadY][newHeadX] = SNAKE;这段代码的核心思路:先算结果,再判断结果合不合法,合法才更新状态。很多人写的时候图方便,先动了蛇身再去检测是否碰撞,这样蛇已经穿过去了,Bug就出现了。做碰撞检测时永远记住:预测,而不是事后补救。
3.4 算法在小游戏里的实际应用:排序、查值、数学优化
别觉得算法离小游戏很远,我举几个实际例子。
成绩排行榜:你给贪吃蛇加一个存档榜,存前五名分数。玩家新得一分,插进去,这就用到排序。最直接的冒泡排序,虽然效率不是最优,但玩家分数就那么几条,实现简单、思路清晰,反而比花里胡哨的快排更适合练手。热搜里经常有人搜“冒泡排序算法c++”,那正好趁这个项目把它用起来。
猜数字游戏:计算机随机生成一个数,玩家猜,提示“大了”或者“小了”。高效的猜法就是二分查找。与其在OJ上干巴巴实现,不如在一个能玩的游戏里体会“为什么每次折半能最快逼近答案”。结合二分查找的思路,你能自己做一个人机猜数字模式:电脑每轮都从可行的数字区间中点猜,最多7次必中,用来虐朋友很有节目效果。
数学优化:判断一个数是不是质数,从小游戏里也能牵扯出来,比如“数独”的校验、扫雷的雷群计算。用i * i <= n代替i < n,简单一个优化,质数判断的运算量从O(n)降到O(sqrt(n)),这个事你在游戏里是实打实感受得到的:游戏卡不卡,差别就在这些细节里。
3.5 输入处理:方向键、非阻塞读取与卡输入
控制台游戏里最折磨人的就是输入。cin是阻塞读入,让你输你才能动,完全没法做实时游戏。所以要用conio.h里的_kbhit()和_getch()。
方向键是个大坑:普通字符_getch()读一次就行,方向键会返回两个字节,第一次是224,第二次才是具体方向。处理方式是这样:
int ch = _getch(); if (ch == 224) { // 224 表示这是个特殊键 ch = _getch(); // 再读一次,拿到真实方向 switch (ch) { case 72: dir = UP; break; case 80: dir = DOWN; break; case 75: dir = LEFT; break; case 77: dir = RIGHT; break; } } else if (ch == 'w' || ch == 'W') { dir = UP; } else if (ch == 's' || ch == 'S') { dir = DOWN; } else if (ch == 'a' || ch == 'A') { dir = LEFT; } else if (ch == 'd' || ch == 'D') { dir = RIGHT; }加了w/a/s/d兼容,是为了方便一部分不习惯方向键的人,也方便有些键盘方向键触发不了特殊键的场景。还有一个小技巧:输入缓冲区里可能积压多帧的按键,如果不及时清,蛇会连着拐好几个弯,甚至在同一帧里按下又松开导致方向检测错乱。可以在每次读取前用while (_kbhit()) _getch();把多余的输入清掉,这算是我写贪吃蛇时踩出来的经验。
4. 经典小游戏实战拆解
4.1 入门项目:猜数字,帮你跑通整个工程流程
猜数字麻雀虽小五脏俱全。功能要求:程序随机生成1到100的整数,玩家输入数字,提示大小,猜对显示次数。流程简单,但你需要面对几个以前没想过的问题:
第一,随机数怎么生成。只写rand() % 100 + 1的话,每次运行生成的序列都一样,因为没播随机种子。正确写法是#include <ctime>,然后srand((unsigned)time(NULL));,再rand() % 100 + 1。
第二,输入非法字符怎么办。玩家输入abc,cin进入错误状态,后面所有输入全部失效。你得用cin.clear()清错误标志、cin.ignore()丢弃残留字符。这个知识点教科书一带而过,实际项目里却天天遇到。
第三,设计多轮和计分。可以让玩家选择猜测模式,比如普通模式和“AI模式”:AI通过二分查找来猜你心里想的数。你只需要维护一个low和high,每轮猜(low + high) / 2,然后根据你回答的“大了小了”调整边界。这个模式能让你直观体会二分的威力:100以内的数最多7次就能猜中。
做完猜数字,你就把随机数、输入处理、状态循环、函数拆分全过了一遍。它不像贪吃蛇那么炫,但它是你从“写习题”到“写程序”的桥梁。
4.2 经典项目:贪吃蛇,控制台游戏的门面
贪吃蛇是控制台游戏的“Hello World”,但想写好也没那么容易。我把它拆成一个完整清单,你可以照着顺序加功能。
第一步,规划地图。隔一个20行40列的区域,外圈画墙,中间空白。用二维数组map[20][40]存状态。打印时遍历数组,墙用#,蛇身用*,食物用@。地图要留好右下角或者独立面板显示分数。
第二步,实现蛇。用vector<pair<int, int>>存蛇身,每个元素是一个坐标对。蛇头是snake[0],蛇尾是snake[snake.size() - 1]。移动时,根据当前方向算出新蛇头坐标,然后按照3.3节里的逻辑处理碰撞和增长。
第三步,生成食物。注意不要让食物生成在蛇身上或者墙里。常用的做法是:while循环里随机生成坐标,只要落在空白的格子上就放出去。因为地图不算太大,这种“拒绝采样”效率完全够用。
第四步,控制节奏。Sleep()的间隔就是蛇的速度。可以随着分数提高慢慢缩短Sleep时间,让蛇越来越快,增加刺激感。
第五步,加分和界面。每吃一个食物加10分,用gotoxy在固定位置刷新分数,而不是重新输出一屏。
做完基础版后,我建议你加两个进阶功能:一个是“暂停”,按空格暂停,再按继续,这考验你对状态切换的掌控;另一个是“排行榜”,结束后输入名字,和之前的历史分数一起排序,这个可以用vector加冒泡排序实现,正好把算法也用上。
4.3 进阶项目:扫雷,递归与二维数组的综合演练
扫雷比贪吃蛇多了一个核心难点:递归展开。当你点开一个周围无雷的格子时,它周围一圈的格子都要自动翻开,这中间还涉及判断边界,非常考验细节。
先解决地图生成。初始化两个二维数组:一个mineMap存放雷的地图,雷用-1表示,非雷存周围雷数;一个showMap存玩家当前能看到的画面,未翻开用#,翻开后显示数字。雷的分布用洗牌思想实现:先生成所有格子坐标的数组,随机挑m个设雷,然后对每个非雷格子遍历周围8个方向,统计雷数。写的时候注意,统计周围雷数时一定要控制边界,越界会读内存垃圾值,数字就会乱。
再解决递归展开。翻开一个格子后,如果它是空块(周围雷数为0),那么它周围8个格子也都要翻开;如果这些子格子也是空块,就继续往外层展开。这个过程天然适合递归:
void openBlock(int x, int y) { if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; if (showMap[y][x] != UNOPENED) return; showMap[y][x] = '0' + mineMap[y][x]; if (mineMap[y][x] != 0) return; for (int dy = -1; dy <= 1; dy++) for (int dx = -1; dx <= 1; dx++) openBlock(x + dx, y + dy); }注意递归出口的顺序:先判越界,再判是否已翻开,最后判断这个格子有没有雷数。这里如果顺序写反,很容易出现“点爆了一片但没展示数字”或者“递归死循环”的Bug。
扫雷还有一个判断胜负的逻辑:所有非雷格子全部被翻开则获胜。这个可以在每次翻开时计数,也可以每次循环遍历整个showMap,数据量不大,怎么实现都行。
如果你想挑战一下,可以再给它加“标记旗帜”功能,右键把格子标成红旗,不允许翻。这个功能需要你在输入处理里区分鼠标操作,用_getch()做不顺手,我建议直接用方向键移动光标、敲空格翻开,也别为难自己。
5. 常见问题与避坑实录
5.1 中文乱码:编码问题比你想的烦
控制台程序默认中文显示经常花屏。原因是Windows控制台默认代码页是GBK(936),而VSCode默认保存UTF-8,编译后的字节流和控制台解码方式对不上,中文就全变成乱码了。
解决方式任选一种:第一种,在代码开头加system("chcp 65001");,让控制台切到UTF-8代码页,但这有个副作用,部分老式Windows字体下中文会变模糊。第二种,把源文件编码改成GBK再编译,很多编辑器右下角可以换编码,但每次切来切去很烦。第三种,用SetConsoleOutputCP(CP_UTF8);加Windows API,效果和chcp类似但更干净。我个人现在都用第三种。
还有一个非常简单粗暴的法子:游戏里尽量少用中文,界面用英文或者拼音,省掉编码问题。但这个方法治标不治本,等你哪天被迫处理中文字符串的时候还是得回来面对编码问题。
5.2 画面闪烁:别再用system("cls")
新手最爱用system("cls")清屏重画,但这个命令会把整个屏幕的内容清掉,然后逐行重新输出,速度不够快时画面就像在不停闪灯。这是控制台游戏体验差的第一大原因。
解决方案就是前面说的gotoxy:哪里变了改哪里。只更新变化的位置,这样既流畅又不闪。典型例子是贪吃蛇,蛇移动只有蛇头、蛇尾两个格子发生变化,其他格子没必要重画。用gotoxy精确定位到变化位置重新输出字符,画面会稳得多。之前我用system("cls")写的第一版贪吃蛇,几乎是闪瞎眼的状态,后来改成局部刷新后立刻顺滑很多。
5.3conio.h是Windows专属,别想着直接跨平台
conio.h里的_kbhit、_getch、clrscr这些函数在Windows上很顺手,但它们不是C++标准库的一部分,Linux和macOS上根本没有。如果你用MinGW在Windows上编译没问题,代码扔到Linux上就会报“找不到conio.h”。
应对方式:做跨平台项目时,用标准库的cin配合termios,或者直接用curses/PDCurses库。但如果你只是写着玩、跑在Windows上,那conio.h就是效率最高的选择。想留个后路的话,把输入函数封装一层,以后换平台只需要改一个文件。
5.4 Sleep卡顿与节奏控制
Sleep(50)简单粗暴,但它有个特点:每帧执行完代码后固定等50毫秒,如果你的更新和渲染逻辑本身耗了20毫秒,那这一帧实际用时就是70毫秒,游戏的节奏会不稳定。
更好的做法是用“时间差”来控制:记录上一帧的时刻,当前时刻减去上次时刻,超过目标帧间隔才执行一帧。像这样:
auto lastTime = std::chrono::steady_clock::now(); while (running) { auto now = std::chrono::steady_clock::now(); auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - lastTime).count(); if (elapsed >= frameInterval) { lastTime = now; processInput(); update(); render(); } }代码量多了一点,但游戏的速度不再受机器性能影响。同样的代码在你电脑上和在别人电脑上跑出一样的手感,这个体验对学习者来说是很好的正反馈。
5.5 链接报错:g++多文件编译别漏文件
多文件编译最常见的报错是“undefined reference to xxx”,意思是编译器在链接阶段找不到某个函数的实现。新手往往盯着那个报错函数名看半天,想不明白自己明明写了函数。
排查思路很简单:你是不是把那个函数的实现放在某个.cpp文件里,但编译命令里漏掉了那个.cpp?比如你写g++ main.cpp -o game.exe,但game.cpp没出现在命令里,那么main.cpp里调用的所有来自game.cpp的函数,链接器全找不到。修正就是编译命令把所有.cpp文件都写上。用VSCode的task时,注意args里要包含你所有的源文件,别只留着当前打开的文件。
还有另一个常见问题:函数声明了但没实现,也会报同样的错。可以先在头文件里搜一下,确认每个声明都有对应实现,再检查编译命令。这类问题多踩几次就有经验了。
5.6 Visual C++ Redistributable报错是什么情况
有时你下载别人编译好的exe文件,双击运行却弹出“microsoft visual c++ redistributable未安装”之类的错误。这不是你的代码问题,而是对方的程序是用Microsoft Visual C++编译的,运行它需要对应版本的VC++运行库。你在自己的机器上开发时,装了Visual Studio或相关组件所以不缺,但用户机器可能没有。
解决办法:去微软官网下载对应版本的“Visual C++ Redistributable”安装包,装完一般就能跑。开发时如果你想让程序尽量免安装运行,可以在项目属性里选择“静态链接运行库”(/MT),这样生成的exe体积大一些,但目标机器上不再依赖VC++运行时。纯MinGW编译的程序一般不会有这个报错,因为MinGW的动态库是libgcc和winpthread,偶尔需要把几个DLL和exe一起打包发出去。
6. 从控制台到图形界面:下一步往哪走
6.1 图形库选型:EasyX、SDL2还是Qt
控制台玩到一定阶段,你就会手痒想给游戏换皮:加图片、加音效、加更复杂的交互。这时候需要引入图形库。Windows上最容易上手的是EasyX,它专为教学而生,函数非常简洁,一个initgraph(640, 480)就能创建窗口,画圆、贴图、显示文字都有简单API。
想要更专业、跨平台的路线,选SDL2。SDL2是用C写的多媒体库,大量游戏引擎的底层都是它。学习曲线比EasyX陡不少,但你能学到窗口、事件循环、纹理渲染这些通用概念。Qt也可以做游戏,而且是跨平台GUI框架,但入门门槛更高,主要用于做应用软件而非游戏。如果你只是想着“把我的贪吃蛇变成有图形的版本”,EasyX是最快路径;如果你想走游戏开发这条路,学SDL2的底气更足。
图形版本和控制台版本的最大区别在于:你不用自己管理“哪个坐标打印什么字符”了,改成了“哪个位置贴什么纹理”。但游戏逻辑部分,比如移动更新、碰撞检测、状态切换,和你前面写的控制台版几乎一模一样。也就是说,你前面学到的东西全部没白费,只是展示层换了一套。
6.2 面向对象重构:让代码复用起来
当你有三个游戏代码时,你会发现大量重复结构:入口、主循环、输入处理、菜单。这时候就可以尝试面向对象重构。可以把游戏抽象成一个基类:
class Game { public: virtual ~Game() {} virtual void init() = 0; virtual void processInput() = 0; virtual void update() = 0; virtual void render() = 0; virtual bool isOver() = 0; void run(); };run()里放通用的主循环框架,调用虚函数。然后SnakeGame、MinesweeperGame分别继承并实现各自的逻辑。主函数里只需要SnakeGame game; game.run();就够了。这一步做完,你会对“接口”和“多态”有一个非常真实的体会,比背一百遍定义都管用。
这里想提醒一点:面向对象重构不必一开始就做。先写一个能跑的过程式版本,等意识到重复代码太多、改一个功能要动好几个地方时再动手,这时候你才真正理解重构的价值。一上来就用复杂的类设计,很容易被设计模式绕晕。
6.3 给游戏加“灵魂”:音效、动画与持久化
控制台游戏加入这些元素后,质感会好很多。Windows下可以用Beep()函数发出简单的提示音,刚做完一个刮刮乐音效时,旁边人都会看过来。动画方面,用Sleep配合gotoxy刷新关键位置,能做出简单的“移动动画”效果。持久化方面,用文件读写把最高分存到score.txt,下次启动时读取,这个功能加上后游戏的完整性立刻提了一个档次。
文件读写这块用标准库的fstream就行:
std::ofstream fout("score.txt"); fout << score; fout.close(); std::ifstream fin("score.txt"); int bestScore; fin >> bestScore; fin.close();注意文件读写有个坑:文件可能不存在,读取前先判断fin.is_open()是否为真,不然直接读会读到垃圾数据。存多条排行榜记录时,每行一条,读取时按行解析,这样数据结构就一起练到了。
6.4 学习路线建议:接下来怎么进阶
如果你把这几个游戏都做完了,下一步我建议按这个顺序走:先给其中一个游戏加上完整的命令行参数,比如支持game.exe -speed 3 -mode hard,练到argv参数的应用;再把游戏逻辑和界面彻底分离,让同一套逻辑代码既能跑控制台版,又能通过EasyX跑图形版;然后再挑战一个更大一点的项目,比如俄罗斯方块或者飞机大战,规则更复杂,涉及方块旋转、下落碰撞等新机制。
俄罗斯方块相比贪吃蛇又有新东西:方块形状用四组坐标表示,旋转就是坐标变换,消行判断是整行检查。飞机大战则是你第一次接触“很多子弹同时移动”的逻辑,能练到对矢量、数组批量遍历的掌控。做完这两个,你对C++的认识已经远超刚学完语法的阶段了,去面试或者做课程设计都会有足够的底气。
我个人做控制台游戏的体会是:代码量不决定你的水平,思考深度才决定。贪吃蛇有人写一百行能跑,有人写两百行还能继续维护;差异不在于会不会用哪个函数,而在于对“数据怎么变化”的理解深不深。每一次Bug、每一种奇怪的操作,其实都在帮你加深对程序运行机制的理解。把基础写的漂亮,比赶进度堆功能重要得多。你把这几个项目敲下来,未来的框架、引擎、工具类库只要原理类似,你都能很快上手。毕竟,控制台游戏这种“元编程”式的练法,放到哪一年都不会过时。