ARTICLE DETAIL

资讯详情

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

C++控制台俄罗斯方块:从零到能玩的全过程与排坑指南

C++控制台俄罗斯方块:从零到能玩的全过程与排坑指南 很多人第一次接触 C课本上写的都是“学生成绩管理系统”“图书管理系统”写得人昏昏欲睡。真正让我把 C 语法吃透的是拿它写了一个能在控制台里玩的俄罗斯方块。别小看这个黑底白字的窗口它把数组、函数、指针、循环、状态机、输入响应、刷新渲染全串起来了而且每写一步都有直观的视觉反馈写完能玩、能炫耀正反馈特别强。这篇文章就把我做“俄罗斯方块 C控制台版”的完整过程拆开来讲从数据结构设计到碰撞检测从无阻塞键盘输入到双缓冲消闪再到各种稀奇古怪的坑一次说清楚。适合刚学完 C 基础语法、想找一个真正练手项目的同学也适合做过但做得不流畅、想看看别人怎么组织代码的爱好者。你不需要会图形库不需要装额外的依赖一个能编译 C 的编译器就够了。1. 项目整体设计与思路拆解1.1 核心需求拆解控制台游戏到底要解决哪些问题做俄罗斯方块控制台版本质上是把“游戏”拆成三个子问题游戏状态怎么存、游戏逻辑怎么跑、游戏画面怎么刷。这三个问题对应到 C 里就是数据结构设计、算法逻辑设计、控制台交互设计。先说数据结构。俄罗斯方块的核心元素是“方块”和“场地”。场地是一个固定宽高的二维格子方块是一个在场地内移动的几何形状。最朴素的存法就是二维数组场地用一个int board[HEIGHT][WIDTH]方块用一个小型的二维数组表示形状。这里不用 class 封装也能写但用 class 会清楚很多。然后是逻辑。游戏每帧要做的事是检测键盘输入、尝试移动方块、尝试旋转方块、判断是否触底、触底后固定方块并检查消行、生成下一个方块、判断游戏结束。这个循环是典型的“游戏主循环game loop”和你在网页里用requestAnimationFrame做的事情本质一样只是换成了 C 来控制节奏。最后是渲染。控制台没有现成的图形接口你要把二维数组的内容“画”到黑窗口里。最简单的做法是每帧清屏再重新输出但会闪烁稍微进阶一点的做法是用 Windows API 的光标定位配合双缓冲彻底解决闪屏。1.2 为什么选 C 而不是 C 或 Python 来做选 C 做这个项目性价比非常高。C 也能写但代码组织会很分散尤其是当你想把方块数据、场地数据、得分状态封装在一起的时候C 的 struct函数指针方案写起来不够顺手。Python 可以用curses库很快搞定但那是库的功劳你对底层输入输出机制的理解不会太深。C 的 class 能让你天然地把“方块”抽象成对象把“游戏”抽象成另一个对象STL 里的vector、map可以在需要的地方用C11 的std::chrono控制帧率比 C 的clock()精确得多。更关键的是C 可以直接调用 Windows APIconio.h的_kbhit、_getch以及windows.h的SetConsoleCursorPosition这让控制台游戏的操作手感和渲染效率都有了保障。提示如果你用的是 macOS 或 Linuxconio.h用不了但可以用termiosselect实现相同的无阻塞输入效果。先按 Windows 版本学会思路再迁移到 Unix 系环境并不难。1.3 代码结构怎么组织才不会写成一坨很多人写控制台游戏喜欢一股脑把全部逻辑塞进main()几百行下来自己都找不着北。我的做法是拆项目哪怕只是练习也要有清晰的文件划分main.cpp入口初始化游戏对象启动主循环。Game.h / Game.cpp游戏类管理场地、当前方块、下一个方块、得分、游戏状态。Tetromino.h / Tetromino.cpp方块类存储形状、位置、旋转状态。ConsoleRenderer.h / ConsoleRenderer.cpp渲染器负责清屏、画边框、画场地、画下一个方块。这样拆的原因很简单每个类只干一件事。游戏逻辑不需要关心渲染细节方块不需要知道场地长什么样渲染器只管拿数据往屏幕上画。你改场地宽度不会影响方块类改渲染方式不会影响游戏逻辑。这就是最基本的“关注点分离”也是以后写任何大项目的习惯基础。2. 核心数据结构地图、方块与旋转2.1 用二维数组搭建游戏场地俄罗斯方块的经典场地规格是 10 列 × 20 行这个比例接近现代电视的 1:2 显示效果。在控制台里一个字符的高度约等于两个字符的宽度所以 10 列宽、20 行高的场地看起来会比较协调。场地数据的存法有很多种我用的是int board[20][10]值 0 表示空格非 0 表示有方块。为什么不用bool因为我想在渲染时区分不同颜色的方块。俄罗斯方块七种形状各有一种颜色int里存一个颜色编号渲染时按编号输出不同颜色的字符颜色功能就留好了扩展口子。这里有个新手容易忽略的细节数组下标的方向。board[row][col]中row是第几行col是第几列。渲染时从上到下、从左到右双重循环row对应 y 轴col对应 x 轴。如果你把 xy 坐标和 row/col 混用后面处理碰撞检测会混乱到崩溃。我自己的习惯是游戏逻辑里一切用(row, col)也就是先写行、再写列屏幕输出也按这个顺序全程统一。2.2 七种方块形状如何存取七种经典方块是I、O、T、S、Z、J、L。每个方块都用一个 4×4 或 3×3 的二维数组表示1 表示有格子0 表示空。网上很多人把七种方块写成一串硬编码的数组像这样int shapes[7][4][4] { { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, // I { {1,1}, {1,1} }, // O // ... };这种硬编码思路最简单但有个问题方块旋转时你不想每一种都手动再算一遍新形状那样代码量会爆炸。更好的做法是只存“初始形状”旋转时用矩阵旋转公式统一处理。矩阵顺时针旋转 90 度公式是新坐标(r, c)对应原坐标(c, size-1-r)直接套进去就能得到旋转后的新数组。不过 O 方块旋转后不变I 方块旋转后形状会从横变竖这些细节都要注意。我建议先用一个临时数组存旋转结果再检查旋转后是否与场地碰撞如果碰撞就不旋转也就是“踢墙”的简化版。2.3 方块对象的设计位置与形状分离设计方块类的时候“位置”和“形状”一定要分开存。位置用row和col两个整数表示方块左上角在场地中的坐标形状用二维数组表示。这样移动方块时只改row、col旋转方块时只替换形状数组各干各的逻辑不会纠缠在一起。很多新手会犯一个错误把方块在场地中的坐标直接写死在形状数组里移动时就整体重算数组。这样做勉强能跑但代码丑到不行后续加“硬降”“幽灵块”等功能时你会想骂人。位置与形状分离之后几乎所有操作都会变得简单左移col--右移col软降row硬降循环row直到碰撞旋转替换形状数组再检查碰撞碰撞就回退2.4 碰撞检测的写法先预测再移动碰撞检测是整个游戏正确性的核心。我的策略是永远先假设移动后的新位置检测合法后再真正修改方块坐标。也就是说拿“未来的状态”去碰撞检测而不是“现在的位置”。具体倒很简单写一个checkCollision(tetromino, newRow, newCol)函数遍历方块形状中所有值为 1 的格子加上newRow、newCol得到场地坐标判断是否越界或是否与场地已有方块重叠左边界newCol localCol 0右边界newCol localCol WIDTH下边界newRow localRow HEIGHT重叠board[newRow localRow][newCol localCol] ! 0这个函数只要写对了后面所有逻辑都轻松。左移、右移、旋转、下落通通走这个函数先试探、再执行。如果你发现自己的游戏出现了“方块穿越场地边缘”“方块叠进已有方块里”的诡异问题八成是这里没有做好。3. 游戏循环与控制台交互3.1 主循环的骨架按下落节奏跑控制台俄罗斯方块和现代游戏一样需要有一个稳定的主循环。循环里做四件事处理输入、更新逻辑、渲染画面、等待一小段时间。用 C11 的std::chrono来控制帧节奏比Sleep()裸奔更可靠while (game.isRunning()) { handleInput(); // 非阻塞读取按键 game.update(); // 按当前速度决定是否下落 renderer.render(game); std::this_thread::sleep_for(std::chrono::milliseconds(16)); }update()里要区分“下落节奏”。正常情况下方块每过一段时间自动下落一行这段时间叫“下落间隔gravity interval”。等级越高间隔越短。移动和旋转操作是即时响应不受间隔影响只有自动下落才受间隔控制。这两个节奏一定要分开否则你会出现“按了方向键但方块要等半天才动”的糟糕手感。3.2 无阻塞键盘输入_kbhit 与 _getch 的用法控制台程序默认的cin是行缓冲的你按一个键它不会立刻返回要等回车。这对游戏来说是致命的你不能要求玩家按一下方向键再按一下回车。解决办法是 Windows 下用conio.h里的_kbhit()和_getch()。_kbhit()的作用是检测键盘缓冲区有没有按键有返回非 0没有返回 0。这个检测不会阻塞程序——这正是游戏循环需要的。_getch()则是从缓冲区读取一个按键字符也不等待回车。两者配合的典型套路是if (_kbhit()) { int key _getch(); switch (key) { case 75: moveLeft(); break; // 左箭头 case 77: moveRight(); break; // 右箭头 case 80: softDrop(); break; // 下箭头 case 72: rotate(); break; // 上箭头 case 32: hardDrop(); break; // 空格 } }注意方向键在_getch()里会返回两个字节第一个字节是 224 或 0第二个字节才是真正的键值。所以上面的代码应该写成if (_kbhit()) { int key _getch(); if (key 224) key _getch(); // 读取方向键的真实键值 switch (key) { ... } }这一步忘了你就会发现方向键完全没有反应。这是控制台游戏新手最容易踩的坑之一。3.3 双缓冲与光标定位彻底解决画面闪烁控制台游戏最常见的问题是闪烁。原因是每帧都system(cls)清屏再重画黑屏和画面交替出现眼睛就看到了闪烁。解决办法有两个组合使用效果最好第一个办法不用 cls用光标定位。Windows API 里有SetConsoleCursorPosition把光标移动到指定坐标后从那里开始输出覆盖旧内容。这样不需要清屏直接把新画面画在旧画面上面闪烁会小很多。HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); COORD pos { x, y }; SetConsoleCursorPosition(hOut, pos);第二个办法隐藏光标。闪烁感很大一部分来自那个不停跳动的小横线。用SetConsoleCursorInfo把光标隐藏掉画面瞬间干净很多CONSOLE_CURSOR_INFO cursorInfo; cursorInfo.dwSize 1; cursorInfo.bVisible FALSE; SetConsoleCursorInfo(hOut, cursorInfo);当然真正的双缓冲比这复杂——先在内存中画好一个完整的“帧”再一次性输出到控制台。控制台版本的“双缓冲”朴素实现是用字符串数组在内存里拼好 20 行画面最后一次性cout输出整个字符串。这样就不会一行一行重画导致视觉上的撕裂和闪烁。两种方法我都试过内存拼字符串再整帧输出的方案最简单、效果最好推荐优先做这个。3.4 颜色输出俄罗斯方块不应该是黑白的七种方块用不同颜色区分视觉体验完全不一样。Windows 控制台支持SetConsoleTextAttribute你可以给输出字符设置前景色和背景色。但直接调用一个字符改一次颜色效率太低了每帧 200 个格子每次变色都是一次 API 调用。我的做法是把颜色信息直接编码到内存帧里每个场地格子不仅存“有没有方块”还存“方块颜色”。渲染时同一行中颜色相同的字符可以连续输出颜色变化时才调用一次SetConsoleTextAttribute。这样做的效果是渲染 20 行画面时 API 调用次数从 200 次降到 30 次左右卡顿和闪烁都明显减少。顺序是先排版、再上色而不是逐个字符边输出边改色。4. 消行、计分与方块队列4.1 消行判断与数组下移的细节消行是俄罗斯方块的爽点所在逻辑本身不复杂方块触底固定后遍历场地每一行如果该行所有格子都非空就把这一行消掉。消掉后的处理方式有两种第一种最直观的把上面所有行整个下移一行。实现上就是从被消的行往上遍历把上一行的数据复制到当前行最上面一行补零。代码大概长这样for (int r rowToClear; r 0; --r) { for (int c 0; c WIDTH; c) { board[r][c] board[r-1][c]; } } for (int c 0; c WIDTH; c) { board[0][c] 0; }第二种稍微炫技一点的用“只保留未满行”的思路。把所有不为满的行收集起来放到新数组的底部上面补空。这种方法处理多行同时消的情况代码更简洁不用写多次搬运。注意俄罗斯方块是有“连消”机制的——你一个方块落下可能同时消满四行这叫“Tetris”。判断消行必须一次性扫描全场不能用“消一行就重新扫描”的方式否则会因为数组移位导致漏判。4.2 计分规则与等级控制积分规则不用搞得太复杂但要有区分度。经典规则是消 1 行 100 分、2 行 300 分、3 行 500 分、4 行 800 分。这样玩家同时消多行时会得到跳跃性的分数奖励玩起来更有追求。等级控制我建议和“消行总数”挂钩。每消满 10 行升一级每升一级下落间隔缩短一档。下落间隔的变化幅度不要一开始就设得太激进我常用的初始值是 800ms每升一级减少 60ms最低到 100ms 就不再减了。这个速度曲线我实测过前几分钟比较悠闲后面逐渐紧张节奏比较合理。等级的另一个用途是渲染。你可以把当前等级、得分、下一个方块都显示在场地右侧的“信息面板”上。这是控制台游戏常见的布局左边 10×20 的游戏区右边空出 12 列显示数据。这样虽然是个小游戏但界面信息完整看起来很专业。4.3 下一个方块与随机队列7-Bag 算法只看当前方块玩起来会有点手忙脚乱如果能在右侧提前显示“下一个方块”玩家体验会提升很多。实现上就是多维护一个“方块生成器”当前方块落地后从生成器里拿下一个再往生成器里补一个新方块。随机生成这里我强烈推荐用“7-Bag”算法而不是每次完全随机。完全随机意味着你可能连续拿到 5 个 S 方块碰到这种情况谁都玩不下去。7-Bag 的思路是把七种方块放进一个袋子每轮从袋子里无放回抽取袋子空了再重新装满。这样保证每 7 个方块里七种形状各出现一次随机性和公平性兼得。实现 7-Bag 非常简单std::vectorint bag {0,1,2,3,4,5,6}; std::shuffle(bag.begin(), bag.end(), std::mt19937(rd()));每次从bag尾部取一个方块取完就再生成一个洗好的新袋子。这样代码几行就搞定但游戏体验的公平感提升非常大。5. 实操过程中踩过的坑与排查实录5.1 方向键读取失败_getch 的二次读取第一次写完我兴奋地编译运行结果发现上下左右键完全没反应反而多了几个H、P、K、M字符出现在屏幕上。排查了半天才想起来方向键在控制台输入流里不是单字节而是两字节前缀 扫描码的格式。_getch()第一次返回的是 224第二次才返回真正的方向键码。没有处理这个 224 前缀程序就把 224 当成了一个字符去处理自然啥也触发不了。修复方式前面已经写了关键是记住拿到_getch()的结果后如果值等于 224必须再读一次。这个机制在不同编译器下略有差异有的返回 0有的返回 224稳妥的做法是把两个值都判断一下。5.2 刷新闪烁严重从 system(cls) 到整帧输出一开始偷懒用system(cls)实现清屏结果程序跑起来像老式电视机坏了一样闪个不停。我查了一圈发现原因有两点一是system(cls)本身速度慢每帧调一次等于人为制造卡顿二是清屏后瞬间重新绘制人眼对暗场和高场的频繁交替特别敏感。换成“内存帧 光标定位 输出整帧”的方案后闪烁问题基本解决。具体的做法是维护一个std::stringstream把场地和 UI 渲染成一个大字符串渲染时SetConsoleCursorPosition到 (0,0)然后一次性把整个字符串cout出去。注意整个字符串最好一次输出不要分多次。5.3 方块穿模与重叠碰撞检测函数没有统一调用有段时间我的方块偶尔会陷进底部方块里或者横向穿越边界很诡异。查来查去发现是因为我在不同地方写了多套碰撞判断逻辑左移的地方写了一遍边界判断下落的地方又写了一遍不同的判断旋转换了自己的判断方式。结果某次改动只改了其中一处另外几处没跟上逻辑就互相对不上了。后来我把碰撞检测收敛成唯一一个函数所有移动和旋转统一调用。这个函数的返回值只有true/false能移动就返回 true不能移动就返回 false调用方根据返回值决定是否更新坐标。从那以后这类问题再没出现过。经验就是游戏逻辑里尤其要 DRYDont Repeat Yourself同一个判断只写一次全项目共用。5.4 控制台字体与窗口大小玩起来憋屈程序写好了但控制台默认窗口又小又窄字符看起来别扭。这里有几个小技巧用system(mode con cols40 lines25)调整控制台窗口大小为 40 列 25 行跟场地加信息面板的尺寸匹配。右键控制台标题栏 → 默认值 → 字体把字体改成“点阵字体”或 16×8 的栅格字体字符比例更方正画面更像像素游戏。如果觉得窗口位置不对可以用MoveWindowAPI 把控制台窗口移动到屏幕中心。字体这个事看起来无关紧要但对整体观感的影响超乎想象。同一个游戏字体选对了黑底彩块的效果真的有点像老掌机。5.5 常见问题速查表现象可能原因解决方案方向键无反应出现 H/P/K/M 字符没有处理_getch()的第一字节 224判断到 224 后再次调用_getch()画面不停闪烁每帧用system(cls)清屏改为内存拼帧 光标定位 整帧输出方块穿模掉进已有方块碰撞检测逻辑分散多处未统一收敛为单一碰撞检测函数并统一调用方块下落速度不受控越快越快下落逻辑没和帧率解耦用std::chrono累计时间控制下落间隔游戏速度在不同电脑上不同直接依赖Sleep(ms)控制速度用时间戳差值控制下落Sleep只做帧率上限方块颜色全是一样SetConsoleTextAttribute没有在切换位置更新在输出字符前根据方块类型重新设置颜色5.6 控制台坐标系统与数学坐标的混淆再提一个容易出问题的点控制台的坐标系统是“左上角为原点x 向右y 向下”而数学里的坐标系是“原点在左下y 向上”。写俄罗斯方块时你要知道自己到底用的是什么坐标系。我建议全程使用“行/列”的方式思考不要转换成笛卡尔坐标。row越大越靠下col越大越靠右。这样当你遍历场地数组输出时for (row)外层、for (col)内层很自然地和屏幕显示对应。旋转公式里我前面提到的新旧坐标映射也要注意是在“行/列”语义下做的不然就会得到错误的旋转结果。5.7 C 编译环境的常见问题写控制台游戏绕不开编译环境配置。很多初学者卡在编译上还没开始写代码就放弃了。常见问题有两类一类是缺少 Visual C 运行库。报错信息经常会看到类似 “VCRUNTIME140_1.dll 缺失” 之类的内容这是编译好的程序在别的机器上缺少运行库导致的装上对应的 Visual C Redistributable 就能解决。另一类是编译器本身没配好。有段时间 vscode 配 C 环境困住了不少人其实核心就是装了 mingw-w64 并配置好环境变量。建议直接下载带编译器的完整包比如 MSYS2 或者 WinLibs 的 GCC 版本然后把bin目录加入 PATH再在 VSCode 里安装 C/C 插件写个 hello world 验证通过之后再做游戏避免把环境问题和代码问题混在一起排。6. 我的个人体会与后续扩展方向俄罗斯方块控制台版这个项目我前前后后写了三遍。第一遍全部塞在main()里能玩但改功能非常痛苦第二遍拆了类结构清晰了但刷新还用的system(cls)闪得难受第三遍才算真正成熟——稳定的主循环、统一碰撞检测、双缓冲渲染、7-Bag 随机代码能看、游戏能玩。说实话把这套代码吃透之后你会发现自己对 C 的理解完全不同了。以前学的是“语法”现在理解的是“程序结构”。什么时候该用类什么时候该用函数什么是状态机什么是渲染循环这些书面上抽象的概念全都有了具体的落点。后面如果想继续折腾有三个方向可以参考。第一个是加“幽灵块”——在当前方块正下方用半透明颜色显示它落底的位置这对玩家预判帮助很大。实现上就是从当前方块位置往下循环移动位置做碰撞检测找到最远合法位置再渲染一遍轮廓。第二个是加“暂存块”功能允许玩家按一个键把当前方块存起来和下一个方块交换。第三个是添加 hard drop 的“落地锁定延迟”让方块在触底后还能短暂移动一下这是现代俄罗斯方块的标配手感优化。最后再分享一个我在实际使用中的小技巧保留一个“调试模式”开关。通过一个布尔变量控制是否显示场地网格坐标、当前方块的 row/col、碰检测状态。这个开关平时关闭出 bug 时打开你能一边玩一边看到内部状态排查问题比猜快得多。我做这个项目时靠这个调试开关省下的时间比写代码的时间还多。
返回列表