资讯详情

C++俄罗斯方块源码解析:矩阵旋转、碰撞检测与模块设计

📅 2026/10/8 7:41:38 | 华诺云谱 👁 阅读
C++俄罗斯方块源码解析:矩阵旋转、碰撞检测与模块设计
简介这套源码包是一份基于C的俄罗斯方块小游戏完整设计实现面向具备基础编程能力、想通过项目实战巩固C知识的个人学习者和开源代码爱好者。工程采用Visual C搭建涵盖游戏主逻辑、矩阵控制、界面刷新等模块可帮助读者理解小游戏开发中的数据结构、界面刷新与消息处理思路。压缩包共27个文件包含10个头文件、7个C源文件另有Visual C工程配置、解决方案、图标及文本说明等整体仅230KB结构精简易读。已有691人学习下载学习热度较高。通过阅读源码和工程组织读者能掌握俄罗斯方块的旋转、消行、计分等核心机制并参照其头文件与源文件的划分方式规范自己的项目结构是一份适合入门到进阶过渡的轻量参考。1. 一个基于 C 的俄罗斯方块小游戏源码到底该拆成哪几块如果你正在找一份“基于 C 的俄罗斯方块小游戏设计源码”来练手、做课设或者想把它当 C 入门后的第一个完整项目直接去网上拷一份完整的 main.cpp 其实是下策。真正的收获不在那几百行代码里而在你对“游戏怎么吃 CPU、怎么响应按键、怎么结算分数”的拆解过程。俄罗斯方块看着规则简单但写清楚会撞上一堆 C 的硬核问题方块旋转时数组越界、消行后列表下标失效、控制台渲染疯狂闪烁、随机出块手感不均匀。这些不是“多写一行 if”能救回来的它逼你把数据结构和状态机理清。常见做法是把项目拆成形状定义、棋盘碰撞、下落逻辑、输入轮询、控制台渲染、消行计分几个独立模块。这篇文章就按这个顺序把我做过的一版控制台小游戏的方案、参数和踩坑经历讲透。适合已经写过循环、数组、类的人只想复制粘贴交作业的读起来可能有点绕但照样能拿走编译命令和核心代码。2. 方块形状与旋转系统为什么我坚持用 4x4 矩阵而不是坐标列表2.1 七种标准方块的形状定义二维数组直接建模我最早的版本把每个方块存成“四个格子的坐标”比如 T 型是(1,0)(0,1)(1,1)(2,1)。旋转时一脸懵坐标换一次要乘旋转矩阵还要处理坐标加减最后干脆写成一堆if (type 0)的分支。后来老实换成 4x4 矩阵用一个const int SHAPES[7][4][4]把七种方块全部静态定义好。你看这段// 7 种方块I, O, T, S, Z, J, L const int SHAPES[7][4][4] { // I { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, // O { {0,0,0,0}, {0,1,1,0}, {0,1,1,0}, {0,0,0,0} }, // T { {0,0,0,0}, {0,1,0,0}, {1,1,1,0}, {0,0,0,0} }, // S { {0,0,0,0}, {0,1,1,0}, {1,1,0,0}, {0,0,0,0} }, // Z { {0,0,0,0}, {1,1,0,0}, {0,1,1,0}, {0,0,0,0} }, // J { {0,0,0,0}, {1,0,0,0}, {1,1,1,0}, {0,0,0,0} }, // L { {0,0,0,0}, {0,0,1,0}, {1,1,1,0}, {0,0,0,0} } };这段代码的逻辑很直接SHAPES[type][row][col]非 0 就代表该格子被方块占据。为什么不用坐标列表因为旋转可以写成矩阵变换碰撞检测直接跟棋盘二维数组逐格比对每一步都是朴素的循环。坐标列表在这两个场景下都会额外封一层结构体、再加一堆坐标偏移计算后面的代码复杂度直接翻倍。参数上我把每块都放在 4x4 的左上角构造I 型占第一行T 型占第二、三行。这块看起来无所谓实际上后面的“出生位置”要跟它配合。如果你把 I 型放在竖直方向出生点会顶到棋盘顶调试时很容易怀疑人生。我一般会把所有形状统一按水平朝向摆放旋转后变成竖直这样SPAWN_X 3, SPAWN_Y 0对每种形状都成立。2.2 顺时针旋转转置加反转别用“我以为会转过去”的直觉矩阵旋转有现成的数学操作但网上不少源码直接手写四种朝向的数组理由是“省时间”。省时间是省了后面做墙踢Wall Kick时会想骂人因为每种朝向都要写一份坐标偏移表。我建议在源码里做一次真正的旋转函数#include cstring void rotateClockwise(int shape[4][4]) { int temp[4][4]; for (int row 0; row 4; row) { for (int col 0; col 4; col) { temp[col][3 - row] shape[row][col]; // 顺时针公式 } } std::memcpy(shape, temp, sizeof(temp)); }核心说明只有一行temp[col][3 - row] shape[row][col]。它把原矩阵的第 row 行第 col 列映射到新矩阵的第 col 行第3 - row列。比如原矩阵的左上角(0,0)会跑到右上角(3,0)这就是顺时针 90 度的效果。必须开一个临时数组再接回原数组如果你在同一个矩阵里直接赋值右下角的数据会被覆盖成别的东西转一圈回来后形状直接烂掉这是新手最容易翻车的地方。这份代码里我用了std::memcpy它只是按字节拷贝性能足够。如果你非要标榜“现代 C”可以把int shape[4][4]换成std::arraystd::arrayint,4,4然后把temp的memcpy改成shape temp;。不过从源码可读性看C 风格二维数组在四乘四这个尺寸上没有任何问题STL 不是这里的主角。2.3 棋盘用 vector 还是 array选 C17 里最不容易错的容器棋盘是动态尺寸吗不是标准俄罗斯方块是 10 列 20 行。那为什么我不用int board[20][10]而是用std::vectorstd::vectorint因为“消行”要删除一行并往顶部插入一行用数组就要手动memmove整片数据用vector可以erase再insert两行代码完事。这个选择不是性能洁癖而是代码可读性优先。const int WIDTH 10; const int HEIGHT 20; std::vectorstd::vectorint board( HEIGHT, std::vectorint(WIDTH, 0) );board[y][x]:y 是行号x 是列号这个顺序在后面的碰撞检测里很关键。千万别写成board[x][y]等你在消行循环里调了半天下标才发现会觉得自己浪费了一个下午。每个格子只存 0 或 10 是空1 是已经被固定住的方块。当前正在下落的那个方块不写入 board它单独拿一份 4x4 矩阵和坐标 (px, py) 参与运算。这样“移动”就变成改 (px, py)不用来回改 board逻辑干净得很。3. 游戏循环与碰撞检测让方块真正“落”下来3.1 主循环骨架输入、更新、渲染三件事绝不能混在一坨很多初版源码长这样一个 while 循环里先是判断键盘、然后移动方块、然后打印棋盘十几行搞定但是后面加“软降”“硬降”“消行动画”时麻了。我把游戏循环拆成三个明确阶段#include chrono #include thread bool running true; auto nextTick std::chrono::steady_clock::now(); while (running) { // 第一阶段只收集输入不直接改游戏状态 handleInput(); // 第二阶段时间到了才更新状态下落、消行 if (std::chrono::steady_clock::now() nextTick) { updateGame(); // 这里的逻辑见 3.2 render(); // 渲染只读数据不产生副作用 nextTick std::chrono::milliseconds(tickSpeed); } std::this_thread::sleep_for(std::chrono::milliseconds(1)); }handleInput()做的事情是把按键转成一个操作标志比如dir -1代表想左移一格updateGame()里再用这个标志去调用tryMove(dir)。这样避免了“按一次键下落两格”之类的竞态。循环周期tickSpeed就是方块自动下降的时间间隔新手期设 800ms你会觉得节奏刚好想要手感硬核就设 300ms但碰撞检测的精度要求更高。3.2 碰撞检测先判定“能不能放”再决定“动不动”方块移动并不是“碰到格子就停”而是“移动到目标位置那个位置上的所有格子是否都合法”。代码上我把它拆成一个纯函数canPlace不修改任何状态只返回布尔值。这函数会被移动、旋转、硬降共用也是单元测试最容易覆盖的地方。bool canPlace( const std::vectorstd::vectorint board, const int shape[4][4], int px, int py ) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (shape[r][c] 0) continue; int x px c; int y py r; if (x 0 || x WIDTH) return false; // 左右越界 if (y 0 || y HEIGHT) return false; // 顶部不会越底线越 if (board[y][x] ! 0) return false; // 碰到已固定的格子 } } return true; }逻辑说明很直白遍历方块的四个行和四个列遇到空格就跳过遇到实格就换算到棋盘的绝对坐标。换算公式是绝对坐标 方块原点坐标 相对坐标。三个 false 分别对应左/右越界、下越界、格子重叠。一个 true 表示可以把方块放在这。这里容易忽略的一点如果py是负数呢比如方块刚出生顶边在棋盘上方之外。代码里if (y 0)也算越界。我见过好几个人把y 0检查去掉理由是不会出现结果在墙踢旋转时方块顶部瞬移到棋盘上面视觉上像穿墙。所以这个检查必须留着。有了canPlace移动就写成bool tryMove(int dx, int dy) { if (canPlace(board, curShape, curX dx, curY dy)) { curX dx; curY dy; return true; } return false; }只有返回false的时候才觉得这一格“不能动”。注意下落碰底时dy 1canPlace返回 false不要马上把方块“固定”到底而是先把curY恢复到上一步再执行freeze()。有些源码直接把方块写进棋盘然后下一次移动检测永远失败整个棋盘逻辑全乱了。3.3 消行实现从下往上扫满行删一行插一行消行的直觉是从上往下扫看到满行就删。错。如果你从上往下删上面行会往下掉但下面行不动后面的空行位置会错位。正确做法是从最底下那一行往上扫描int clearFullRows(std::vectorstd::vectorint board) { int lines 0; for (int y HEIGHT - 1; y 0; --y) { bool full true; for (int x 0; x WIDTH; x) { if (board[y][x] 0) { full false; break; } } if (full) { board.erase(board.begin() y); board.insert(board.begin(), std::vectorint(WIDTH, 0)); y; // 删了一行后同一位置换成了新插入的行重新检查 lines; } } return lines; }board.erase(begin()y)会把满行从 vector 里移除insert(begin(), WIDTH个0)在顶部插入新空行。因为删掉一行后原第 y 行的下方内容整体上移原来第 y 行的位置现在站着原本第 y-1 行的内容所以y让 for 循环里的--y中和一下等于原地再检查一次。这段代码在 HEIGHT20 时没有任何性能压力你不需要用什么黑科技。还有一点insert在 vector 头部是 O(n) 操作但棋盘只有 20 行一次最多插 4 行完全无所谓。如果你将来要改成 100x100 的大棋盘就换成环形缓冲区现在别过度设计。3.4 输入处理方向键是非阻塞读取不是 std::cinstd::cin在终端等回车游戏会卡死。我用的方案是 Windows 上的_kbhit()和_getch()它们在conio.h里。如果你在 Linux/macOS 上编译这俩函数没有可以用 termios 设置非阻塞模式这篇文章以 Windows 控制台为主因为多数课程设计跑在 Windows。#include conio.h void handleInput() { if (!_kbhit()) return; int key _getch(); if (key 224 || key 0) { // 方向键是双字节第一个字节是 224 或 0 key _getch(); if (key 72) rotateRequested true; // ↑ 旋转 else if (key 75) moveDx -1; // ← 左移 else if (key 77) moveDx 1; // → 右移 else if (key 80) moveDy 1; // ↓ 软降 } else if (key ) { hardDropRequested true; } else if (key 27) { // Esc running false; } }注意方向键在_getch()返回 224然后紧跟着第二个字节才是真正的方向码72 上、75 左、77 右、80 下。如果你只在第一次_getch()判断会发现方向键全部失效。空格键的 ASCII 是 32记得 不是0。我年少时把 写成0结果空格没反应Esc 倒是把游戏退出了花了一天才发现。4. 渲染与源码组织把游戏逻辑和显示彻底分开4.1 控制台渲染不再清屏ANSI 光标移动是最便宜的方案很多人写控制台游戏第一件事就是system(cls)然后重新打印整个画面。这会导致屏幕闪烁而且cls每次执行要启动一个新进程效率奇低。我在一版源码里改成std::cout \033[H这是 ANSI 转义序列把光标移到左上角之后重新输出画面不清屏、不滚动。效果是画面原地刷新不再一抖一抖。void render() { std::cout \033[H; // 光标归位到左上角 for (int y 0; y HEIGHT; y) { std::cout #; for (int x 0; x WIDTH; x) { char cell .; if (board[y][x] ! 0) cell #; // 当前方块覆盖到这一格时也算实心 if (shapeOnCur shapeOnCur[y][x]) cell O; std::cout cell; } std::cout #\n; } std::cout Score: score Level: level \n; }这里shapeOnCur是我在 render 前临时生成的一个覆盖矩阵把当前方块按 (curX, curY) 投射到一个 10x20 的临时数组里再与棋盘合并显示。你也可以在循环里动态算shape[r][c]对应的坐标是否和当前格重合。\033[H在 Windows 10 以上默认支持前提是终端不是老式 cmd 的兼容模式。如果发现输出的是[H字符在 main 开头加一行system();即可启用 VT 模式这个操作很玄学但确实有用。边框#和内部方块#混在一起我喜欢用#代表墙.代表空O代表当前方块。这样一屏能看出运动和落点。4.2 源码模块划分Board、Tetromino、Game 三个类别再写 main.cpp 大乱炖标题里带“源码”就意味着别人会拿着你的代码去阅读和改造。如果你把全部逻辑塞进一个 main.cpp别人读起来是一本一千行的 C 语言教材。我把源码拆成三个文件加上一个测试文件tetris/ ├── main.cpp // main 函数、循环调度 ├── game.h/.cpp // Game 类状态机、计分、等级 ├── board.h/.cpp // Board 类棋盘、碰撞、消行 ├── shape.h/.cpp // 方块定义、旋转 └── test.cpp // 单元测试第 6 章会讲Board 头文件长这样#pragma once #include vector class Board { public: Board(int w, int h); bool canPlace(const int shape[4][4], int px, int py) const; void freeze(const int shape[4][4], int px, int py); int clearFullRows(); int width() const { return width_; } int height() const { return height_; } const std::vectorstd::vectorint data() const { return grid_; } private: int width_; int height_; std::vectorstd::vectorint grid_; };设计要点是把canPlace声明为const因为它不修改棋盘freeze负责把当前方块写进grid_clearFullRows返回消掉的行数。Game 类持有Board和当前形状数据所有“规则”都放在 Game 里底层 Board 不知道什么是“游戏”。这个分法不是随便定的——如果哪天你想加网络对战只需换 Game 的输入输出Board 完全不变。4.3 在 VSCode 里配置 C/C 编译环境从目录到可执行文件如果你是跟着课程设计走的大概率用的是 VSCode 加 MinGW。先确认g在 PATH 里然后写一个极简的构建命令。我一般不起用 CMake因为单项目一个命令就够了g -stdc17 -O2 -Wall main.cpp game.cpp board.cpp shape.cpp -o tetris.exe-stdc17让std::vector::insert、std::chrono这些特性不报警告-O2是发布优化能消除不少编译警告-Wall打开常见警告看到 warning 不要无视。如果要调试把-O2换成-g才能在 VSCode 断点单步。运行则是./tetris.exe。在 VSCode 里按CtrlShiftB构建需要在.vscode/tasks.json写一个任务{ version: 2.0.0, tasks: [ { label: build tetris, type: shell, command: g, args: [ -stdc17, -g, -Wall, main.cpp, game.cpp, board.cpp, shape.cpp, -o, tetris.exe ], group: { kind: build, isDefault: true } } ] }这段 JSON 的核心是args数组它告诉 VSCode 在终端里执行什么命令。如果编译时提示iostream找不到多半是编译器没装好不是代码问题。VSCode 只是编辑器它不管 C 编译你得先能手动在终端下跑通 g再回到编辑器构建。这一步是很多新手被卡住的地方别指望 VSCode 帮你变出编译器。5. 避坑这份源码最容易翻车的四个点我都替你踩过5.1 场景一方块在墙边旋转瞬间“穿墙”甚至数组越界现象I 型方块贴右墙时按上键方块有一部分显示在墙外或者程序直接崩溃。原因rotateClockwise只是把 4x4 矩阵变了但你依然把旋转后的坐标放在原来的 (px, py) 上。旋转后形状的占位可能从 4 格宽变成 1 格宽但矩阵里非 0 格子的横坐标会落在 px0 到 px3谁保证 px3 小于 WIDTH没有。解决每次旋转前先做墙踢Wall Kick。标准做法是尝试多个偏移const int kikOffsets[][2] { {0, 0}, {-1, 0}, {1, 0}, {0, -1}, {-2, 0}, {2, 0} }; void tryRotate() { int temp[4][4]; std::memcpy(temp, curShape, sizeof(temp)); rotateClockwise(temp); for (auto off : kikOffsets) { if (canPlace(board, temp, curX off[0], curY off[1])) { std::memcpy(curShape, temp, sizeof(temp)); curX off[0]; curY off[1]; return; } } // 所有踢法都不行放弃旋转 }墙踢偏移数组{0,0},{-1,0},{1,0},{0,-1},{-2,0},{2,0}是模拟简单踢墙的手感。先试原位不行向左踢 1 格、向右踢 1 格还不行就试着向上踢、向左踢 2 格、向右踢 2 格。顺序不能乱否则旋转手感会很“滑”。5.2 场景二消行后上面的方块不往下掉只少了中间一行现象一行消掉后它上面的方块悬空了下面还有几行满行没消。原因你从上往下扫描然后把board[row]删掉但删除后后面行号集体减一循环却继续往下走。如果连续两行都满只消了上面那一行下面那行逃过一劫。解决严格从下往上扫描像 3.3 节里那样。同时注意erase后迭代器/行号失效。如果你用for (auto it grid_.begin(); it ! grid_.end(); )删除后it会失效必须重新赋值或者用索引。我用的是索引加y的兜底实测稳。怕出错就在删除后直接break重新开始扫描反正最多删四次性能无所谓。5.3 场景三键盘响应像抽搐按一下左移两格甚至瞬移到底现象按住左箭头不放方块不是一格一格移动而是猛然跳到底或者连续横跳。原因_kbhit()在键盘缓冲里判断“有没有人按”你按住方向键时操作系统会持续往缓冲区塞同一个按键事件每帧读取可能一次读几个。如果你在一次handleInput()里连续_getch()就会在同一帧移动多次。解决把输入处理限制为“每帧最多处理一次位移”并且把方向按压作为一个“目标方向”存储。我用的方法是每个方向更新时先判断dx0如果已经是同一方向就不再重复修改void tryMoveLeft() { if (pressCooldown 0) return; // 冷却时间 if (canPlace(board, curShape, curX - 1, curY)) { curX--; } pressCooldown 5; // 五次循环后再允许下一个位移 }这里的pressCooldown单位是主循环的帧数5 帧约等于 50ms手感处于“不黏键”和“不迟钝”之间。如果你觉得灵敏度不够可以改成基于std::chrono的时间差判断但控制台小游戏没必要那么精密。5.4 场景四画面闪烁重绘时出现残缺的乱码现象每次system(cls)屏幕像得了帕金森一样疯狂闪或者在某些终端里输出中文方块图形变成乱码。原因cls是启动一个外部命令清屏再逐行cout终端底层先把旧内容覆盖再清空闪烁不可避免。乱码则是你用 UTF-8 编码保存源码而终端默认 GBK 编码方块字符集不匹配。解决用 ANSI 光标归位和直接绘制 ASCII 符号。如果你一定要用方块图形 ▓ █ ▌把源码文件以 GBK 编码保存VSCode 右下角点编码改或者把控制台代码页改成 UTF-8// Windows 10 启用 UTF-8避免中文/x96符号乱码 system(chcp 65001 nul);不过我更推荐统一用#、O、.不依赖任何编码纯 ASCII 在任何终端都不会翻车。想要更好的颜值可以直接引入ncurses或者SFML那就不是标题里“源码”二字的范畴了。5.5 场景五随机出块连续来同样的形状感觉像假随机现象连续五次拿到 I 型或者 20 次都没见过 S 型玩家觉得随机性被操纵了。原因rand() % 7是均匀分布但均匀分布不等于不重复你确实可能连续 roll 到同一个数。解决用经典 7-bag 洗牌算法每 7 个方块一组组内包含 0 到 6 各一次组内打乱顺序然后逐次取用。代码只增加十几行std::vectorint bag_; // 成员变量 int nextShape() { if (bag_.empty()) { for (int i 0; i 7; i) bag_.push_back(i); std::shuffle(bag_.begin(), bag_.end(), std::mt19937(std::random_device{}())); } int type bag_.back(); bag_.pop_back(); return type; }std::shuffle需要algorithm和random。这里的std::mt19937是梅森旋转引擎比rand()质量高出块分布会平滑很多玩家不会察觉连续倒霉但也不会总在某个形状上卡住。6. 把它变成真正值得交付的小游戏三段验证与三个必加功能你现在已经有一个能跑、能玩的源码但离“负责任”还差一步验证。我的血泪教训是俄罗斯方块的逻辑全是离散矩阵运算肉眼测试不一定看得见边界 bug。先把核心函数写成可测的单元再谈后续功能。6.1 给旋转与消行写最小断言在test.cpp里写几个assert专门攻击旋转和消行的边界#include cassert #include vector void testRotate() { int shape[4][4] { {1,1,0,0}, {0,0,0,0}, {0,0,0,0}, {0,0,0,0} }; rotateClockwise(shape); // 原来的横条应变成竖条最左列有值 assert(shape[0][0] 1); assert(shape[1][0] 1); assert(shape[2][0] 0); } int main() { testRotate(); // 构造一个 10 行 10 列的小棋盘填满最后一行 Board b(10, 10); // …… 这里按你的 Board 接口填充数据 // assert(b.clearFullRows() 1); return 0; }旋转测试不用完整验证 16 个格子只需要验证两个关键坐标映射(0,0)变到(0,3)(0,1)变到(1,3)即“横条变竖条”。三个assert能抓住 90% 的旋转实现错误。消行测试用一个 4x4 的小棋盘造满行跑一次检查行数变化和空行插入位置。把这些断言加进构建命令跑一遍出结果再进主循环。6.2 三件套预览、7-bag 和等级速度做完验证再谈体验。三个必加功能按难度排序下一个方块预览在 Game 里保存nextType_渲染时在右侧画 4x4 块取新块时把nextType_赋给当前然后再从 bag 取一个新值放预览。7-bag 随机已经写在 5.5这是防止玩家摸清规律最简单的方式。等级速度表每消 10 行升一级下落间隔缩短。建议用一张常量表而不是算一个复杂公式等级下落间隔(ms)一次消4行加分1800400272050036506004580700等级只影响tickSpeed不影响软降速度。软降永远是你按住 ↓ 时每秒移动 20 格这样玩家在高等级还能靠手动微调不会直接失控。6.3 源码整理的最后两步把测试文件纳入构建并保证g -Wall不产生任何 warning。然后跑一个 5 分钟局把速度设到最快特意贴墙旋转、连消两次四行观察是否崩。我当年没做这步交源码前在评委面前把 I 型转到墙里直接数组越界退出差点没毕设。后来我把“旋转踢墙、消行、7-bag”三块全部抽成独立函数并且只用断言验证再没出过这种幺蛾子。这份 C 俄罗斯方块源码最终值不值得投入就看它能不能在你本机一键编译、连续跑 5 局不闪退、代码结构能让人一眼看懂。把这三件事做到你学到的东西比盲目抄十份源码都多。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑