C/C++手写终端俄罗斯方块:状态建模、时间步进与碰撞检测 📅 发布时间:2026/9/18 11:22:07 👁 浏览次数: 1. 为什么俄罗斯方块是 C/C 练手项目里性价比最高的一个我见过太多人在学完 C/C 语法之后卡在原地指针会用结构体会写可一旦让你从零做一个能玩的东西脑子就空了。这时候最常见的建议是写个学生管理系统——增删改查加文件读写练完你其实还是不会写程序因为整个过程中你从没处理过一个真正的问题时间。俄罗斯方块看着简单实际上把一个交互式程序该有的难点全塞进去了。它需要你在没有操作系统的帮助下自己维护一个游戏循环需要把每 0.8 秒下落一格这种时间概念翻译成代码需要在用户按方向键的那一瞬间判断这个动作合不合法还需要在一个字符终端里把画面刷出来而不闪屏。这四件事恰好就是所有实时程序——游戏、监控面板、串口上位机、终端工具——的骨架。更关键的是它的复杂度可控。规则简单到三句话能讲完但代码量能控制在 400 行左右一个晚上就能从零写完并且能跑。你不需要学图形库、不需要配 OpenGL、不需要理解渲染管线一个g tetris.cpp -o tetris就完事了。这种当天看到成果的反馈是坚持把项目做完的最大动力。这篇文章里我把完整源码逐段拆开讲包括数据结构怎么设计、为什么不用运行时旋转、碰撞检测的边界条件该怎么写、控制台不闪屏的正确姿势以及我在调试这个项目时踩过的几个坑。代码是单文件、无第三方依赖、Windows 和 Linux/macOS 都能编译的版本总共四百多行直接抄下来就能用。1.1 这个项目真正要练的四个能力先把这个项目拆开看它到底在练什么。第一是状态建模。棋盘是什么方块是什么一个方块落在哪里这个信息该怎么表示这些问题看似是数据结构问题本质上是你对游戏世界的抽象能力。抽象得好后面所有代码都顺抽象得烂写到旋转那一块你就开始打补丁了。第二是时间管理。很多初学者的第一反应是这样写while (true) { Sleep(800); moveDown(); }这段代码能跑但你马上会发现按左键方块不动因为程序正卡在Sleep里键盘输入根本没机会被处理。等你改成Sleep(16)再计数又会遇到掉帧之后方块瞬移一大截的问题。正确做法是把累计时间和物理步进拆开后面第 3 节会详细讲。第三是边界与碰撞。这是最容易写出隐藏 bug 的地方。方块贴左墙能不能左移旋转之后越界了怎么办消行之后行索引该怎么维护这些问题里只要有一个想岔了症状往往不是崩溃而是偶尔不对劲极难排查。第四是输出控制。控制台不是画布每一帧都得你自己拼字符串然后一次性写出去。用system(cls)清屏的话你会看到肉眼可见的闪烁帧率直接掉到个位数。1.2 一个容易被忽略的现实你的读者是终端还有一点值得提前说清楚。这个项目最终是跑在终端里的而终端的坐标系统和你想象的完全不同没有像素只有字符网格没有 z 轴只有从上到下刷新的顺序而且字符宽度还不一样——ASCII 字符占一列中文字符占两列。这就带来一个非常实际的约束方块和棋盘的内容一定要用等宽字符画。如果你为了好看用█这种全角块状字符去画方块你会发现行与行之间永远对不齐因为一个█在终端里占两列宽而空格只占一列。我自己第一次写的时候就是被这个坑折磨了半小时最后老老实实用[]两个 ASCII 字符表示一个方块格也就是每个格子占两列宽棋盘 10 列就是 20 个字符宽度这样既接近正方形比例又不会错位。同样的道理界面上的提示文字我建议全部用英文。不是因为崇洋媚外而是因为中文在 Windows 控制台里的编码问题非常麻烦第 5 节我会专门讲这件事的完整成因和规避方案。先用 ASCII 把功能跑通再考虑本地化这个顺序不能反。2. 数据建模用 4×4 包围盒把 7 种方块统一成一张表我见过不少实现是给每种方块定义四个坐标比如 T 形写成(1,0)(0,1)(1,1)(2,1)旋转的时候现场算一遍。这个做法在纸面上很优雅但真正写起来会持续给你添麻烦旋转之后坐标要重新对齐I 形和 O 形的中心点还跟别人不一样稍微不注意方块就会漂移一格。我最后采用的方案是包围盒 预计算旋转表每种方块用一个 n×n 的布尔矩阵表示n 取 2、3 或 4四种旋转态在程序启动时一次性算好存进三维数组之后运行时只做查表不做任何几何计算。2.1 为什么包围盒边长要区分对待这里有一个必须解释清楚的细节三种边长不是随便定的。I 形方块用 4×4 的盒子因为它在四个方向上的最长边都是 4如果塞进 3×3 就装不下。O 形用 2×2因为它就是个正方形用更大的盒子反而会导致旋转时出现位置漂移。J、L、S、T、Z 这五种用 3×3因为它们都在 3×3 的范围里活动。关键点是旋转必须在相同的 n×n 盒子里进行。如果你图省事把所有方块都塞进 4×4 的盒子那么 3×3 的那五种方块在旋转时内容会在盒子里整体平移一格。表现出来就是你按一次旋转键方块除了转向还诡异地往右跳了一格。这个 bug 看起来像手感问题实际是数学问题。代码里的定义是这样的enum { P_I 0, P_J, P_L, P_O, P_S, P_T, P_Z }; static const int BOX[7] { 4, 3, 3, 2, 3, 3, 3 }; static const char* BASE[7][4] { { ...., XXXX, ...., .... }, // I { X.., XXX, ..., ... }, // J { ..X, XXX, ..., ... }, // L { XX, XX, , }, // O { .XX, XX., ..., ... }, // S { .X., XXX, ..., ... }, // T { XX., .XX, ..., ... }, // Z };用字符串画形状的好处是肉眼可验证。你盯着.X. / XXX / ...看两秒钟就知道这是 T 形朝上的样子比一堆坐标对更容易 debug。2.2 启动时一次性生成四个旋转态旋转的数学就是一个原地转置加翻转。对顺时针旋转公式是新[r][c] 旧[n-1-c][r]用代码写出来只有三行static bool shape[7][4][4][4]; // [类型][旋转态][行][列] static void buildShapes() { for (int t 0; t 7; t) { int n BOX[t]; for (int r 0; r n; r) for (int c 0; c n; c) shape[t][0][r][c] (BASE[t][r][c] X); for (int rot 1; rot 4; rot) for (int r 0; r n; r) for (int c 0; c n; c) shape[t][rot][r][c] shape[t][rot - 1][n - 1 - c][r]; } }这里有个设计取舍值得说明我完全可以不预计算在每次按键时临时算旋转。但预计算有两个好处。一是运行时不引入任何计算错误的机会旋转结果在启动时就固定了。二是逆时针旋转可以直接通过(rot 3) % 4拿到不需要单独写一套反向公式——你要逆时针转等价于顺时针转三次这个技巧在状态机里非常常用。顺带说一句如果你想验证这套表算得对不对可以在buildShapes()后面加一段打印把 28 个状态全 dump 出来看一眼。我在第一次实现时就是这么干的结果发现 J 和 L 的基准形状写反了旋转出来的形状完全不对。这个检查花不了两分钟能省掉半小时的怀疑人生。2.3 棋盘用 int 矩阵不用 bool 矩阵棋盘我用int board[20][10]而不是bool。原因很简单int里可以存方块的类型编号这样渲染的时候就能按类型上色落下去的老方块还能保持原来的颜色。如果只用bool整个棋盘就是一坨没有区别的灰块视觉上很难受。int board[ROWS][COLS]; // 0 表示空格1..7 表示七种方块的颜色编号顺手提一下坐标约定board[y][x]里第一维是行从上往下第二维是列从左往右。这个顺序和屏幕坐标一致渲染的时候不用再反过来想能少犯很多错。初学的时候经常有人写成board[x][y]结果图形看起来是转置的排查起来要花点时间。3. 时间步进把下落从循环里拆出来这一节是我认为整个项目里最重要的部分因为它决定了这个程序是玩具还是像样的程序。3.1 帧率无关的落块计时器正确的结构是这样主循环以固定频率跑我这里用 60 帧左右每一帧做三件事——拉取输入、推进时间、重绘画面。下落逻辑不再是一个独立的Sleep而是挂在一个累加器上static void updateGame(Game g, double dt) { if (g.paused || g.over) return; g.dropTimer dt; while (g.dropTimer g.dropInterval) { g.dropTimer - g.dropInterval; if (tryMove(g, 0, 1)) continue; lockPiece(g); g.dropTimer 0; if (g.over) return; } }dt是上一帧到现在经过的秒数用std::chrono::steady_clock测量。注意这里用的是steady_clock而不是system_clock——后者会被系统时间调整影响用户改一下电脑时间你的方块就会瞬移。这是个很冷门但真实存在的坑。用while而不是if是故意的。假如某次系统卡了一下dt累积了 0.3 秒而当前下落间隔是 0.1 秒那么这一帧应该推进三次而不是一次。用while就能把丢掉的步数补齐方块不会有视觉上的跳跃感。不过要小心死循环如果dropInterval被算成 0 或者负数这个while就永远出不来了。所以我在等级加速那里强制设了下限double iv 0.80 - (g.level - 1) * 0.065; if (iv 0.08) iv 0.08; g.dropInterval iv;0.08 秒一格也就是每秒 12.5 格这已经是人类反应能力的极限附近了。再快就不是难度问题而是没法玩。3.2 主循环还需要一个防跳帧的保险主循环里还有一行容易被忽略但很重要的代码double dt std::chrono::durationdouble(now - last).count(); if (dt 0.10) dt 0.10;为什么要把dt截断因为用户可能会把终端窗口最小化、或者系统在跑别的重活导致某一帧的dt变成两三秒甚至更久。如果不截断下一帧你会看到方块瞬间掉到底部——因为累加器一次性消耗完了所有时间。截断到 0.1 秒之后最坏情况也就是掉十格左右视觉上还能接受。3.3 按键事件和按键状态的本质区别这是控制台游戏和图形界面游戏最大的差异之一控制台里你拿到的是按键事件流不是按键状态。也就是说程序只能在用户按下某个键的那一刻收到一个字节它没法直接查询左箭头现在是不是被按住。那为什么实际上按住方向键能连续移动因为大多数终端在按住键时会触发系统的按键重复产生连续的字节流。Windows 的_kbhit()会把这些重复都报出来Linux 的原始模式下read()也会一次次读到重复字符。所以你按下不放程序实际上收到了几十个按下左键的事件——效果上等价于按住。理解这一点很重要因为它决定了你的按键处理只能写成收到一个事件就执行一次动作的模型。软降也是同样的逻辑case K_DOWN: if (tryMove(g, 0, 1)) { g.score 1; g.dropTimer 0; } break;每收到一次按下键的事件方块就下移一格、加一分、并重置下落计时器。按住不放时系统不断重复发送手感就和加速下落完全一样。4. 碰撞检测与旋转两个函数写错整个游戏就废了如果整个项目只能仔细写两个函数我会选collides和tryRotate。4.1 collides 的四个边界条件碰撞检测的本质就是遍历方块包围盒里的每个实心格把它换算到棋盘的绝对坐标然后检查这个坐标是否合法static bool collides(const Game g, int type, int rot, int px, int py) { int n BOX[type]; for (int r