资讯动态

C语言俄罗斯方块小游戏:从数据结构到终端渲染完整实现

发布时间:2026/10/16 5:29:24 来源:尧图企业网站定制
刚开始学 C 语言的循环、数组很多人都会冒出同一个念头写一个能真正跑起来的小游戏。C语言实现的俄罗斯方块小游戏几乎是最理想的练手项目它不像链表排序那样抽象你把代码编译完终端里真的会掉方块、能消行这种反馈感是刷一百道题都给不了的。但真动手之后你会撞上几个上课从没讲过的问题终端不是画布怎么“画”方块按方向键想立即生效但getchar()明明在等回车方块转个身就卡进墙里怎么办这几个坎都不高但顺序摆在一起足够劝退第一批动手的人。这篇文章不是让你抄一遍代码而是按“数据结构 → 渲染 → 输入 → 规则 → 主循环”这条路径把每个环节为什么这样设计讲清楚。适合读到指针、结构体之后的初学者也适合想快速回顾 C 语言、给自己找个完整项目的开发者。文末的代码均为可编译的 C89/C99 风格核心部分我会直接贴出来。1. 数据结构直接决定后面所有逻辑的复杂程度我见过很多人写俄罗斯方块第一件事就是研究怎么用图形库画个漂亮界面。我的经验恰恰相反界面是最后才该考虑的事。动手之前先把“游戏世界”用什么数据结构表达想清楚后面旋转、碰撞、消行全是小事如果一上来就纠结渲染你会在画方块上浪费大量时间最后发现核心玩法根本没法跑。1.1 棋盘与下落方块两个坐标体系的互相转换整个游戏里只需要两块核心数据已经固定在地图上的方块和正在下落的那一个方块。固定部分用一张二维数组这也是最容易想到的方案#define ROWS 20 #define COLS 10 int board[ROWS][COLS]; // 0 表示空1 表示已经有方块所有掉到底部的方块都写进这张表画面渲染、碰撞检测都以它为准。正在下落的方块不能直接写进 board否则没法移动了。它需要独立的一组变量描述int shape; // 方块类型0~6对应 7 种方块 int rot; // 旋转状态0~3 int x, y; // 方块矩阵左上角在 board 里的列坐标和行坐标注意这里的 x 对棋盘是“列”y 是“行”。写成 (x, y) 是习惯但数组下标永远是board[y][x]这个顺序搞反是最隐蔽的 bug。有了这套坐标每个方块格子在 board 中的位置就可以直接算出来。假设方块用一个 4×4 矩阵表示矩阵里的第 r 行第 c 列是 1那它在棋盘上的位置就是int board_row y r; int board_col x c;这就是两个坐标系的换算关系后面写碰撞检测、渲染用的全是这一个公式。所有复杂逻辑都建立在这个简单关系上所以它必须被放在最前面想清楚。1.2 七种方块的四维数组定义为什么这么存俄罗斯方块的标准七种方块按形状分为 I、O、T、S、Z、J、L。它们的大小都在 4×4 的格子里放得下所以最直观的存法是用一个四维数组#define SHAPES_COUNT 7 #define ROT_COUNT 4 #define CELL 4 static const int SHAPES[SHAPES_COUNT][ROT_COUNT][CELL][CELL] { // I 方块1 表示有格子 { { {0, 0, 0, 0}, {1, 1, 1, 1}, {0, 0, 0, 0}, {0, 0, 0, 0} }, // ... 其余 3 个旋转状态 }, // O、T、S、Z、J、L 依次往后写 };上标结构是[方块类型][旋转状态][行][列]。7 种方块每种 4 个旋转状态每个状态一个 4×4 矩阵。矩阵里 1 代表“这里有格子”0 代表空。这套方案看着浪费——7×4×16 共 448 个整数但现在机器根本不在乎这点内存换来的却是代码极度简单。旋转方块时只需要写int new_rot (rot 1) % ROT_COUNT;然后直接用SHAPES[shape][new_rot]去碰撞检测、渲染其余的不用动。另一个常见的存法是只存每个旋转状态下 4 个格子的坐标点也就是[7][4][4][2]。内存更省但读代码时要在坐标和矩阵之间来回换算对初学者不友好。我的建议是先用 4×4 矩阵把逻辑跑通想优化内存再换游戏项目完全不需要在数据压缩上较劲。1.3 旋转用预置矩阵而不是临时算旋转很多人会下意识地想要不要写一个“旋转函数”把矩阵顺时针转动 90 度数学上没问题实操上会带来一个麻烦旋转中心点怎么选I 方块横着是[1,1,1,1]竖着是[1,0,0,0; 1,0,0,0; ...]旋转中心稍微偏一点整个方块在棋盘上的位置就会“抖”一下。为了解决这个偏移你还要额外处理中心点补偿。到头来代码量多了一截bug 还特别难找。预置 4 个旋转状态的本质是把旋转数组的计算提前到写代码时完成运行期只查表。这有一个额外好处你想设计自定义方块只需要往数组里塞一组新数据逻辑一行都不用改。所以我能给的建议是在入门阶段别在旋转上秀数学老老实实用查表法。数据模型定完你已经把最难的部分解决了。接下来才到画界面。2. 终端不是画布怎么把游戏“画”出来题目里说的是“简单小游戏”我默认它跑在命令行终端里不依赖任何图形库。很多人会怀疑终端只能打印文字怎么画游戏画面其实原理不复杂——把每个格子当成一个字符块用printf输出到终端配合光标定位就能实现动态画面。2.1 先拼屏再输出在字符数组中完成双缓冲渲染最容易犯的错误是打个格子就printf一次结果画面一闪一闪上下内容还互相窜。正确做法是先准备一块字符缓冲区把一整个画面拼好再一次性输出。这个方法在图形学里叫双缓冲在终端游戏里一样适用。代码可以这样组织char screen[ROWS][COLS * 2 3]; // 每格占 2 个字符宽度加上左右边框和行尾 void render() { // 第一步把空格刷满整个屏幕缓冲区 for (int r 0; r ROWS; r) { int pos 0; screen[r][pos] |; screen[r][pos] ; screen[r][pos] |; screen[r][pos] \0; } // 第二步把 board 中已经固定的方块写进缓冲区 for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (board[r][c]) { screen[r][c * 2 1] #; screen[r][c * 2 2] #; } } } // 第三步把当前正在下落的方块写进缓冲区 for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (SHAPES[shape][rot][r][c]) { int rr y r; int cc x c; if (rr 0 rr ROWS cc 0 cc COLS) { screen[rr][cc * 2 1] #; screen[rr][cc * 2 2] #; } } } } // 最后一次性输出 printf(\033[H); // 光标回到左上角 for (int r 0; r ROWS; r) { printf(%s\n, screen[r]); } }眼熟吗这就是把 board 里的数字转成可见字符。所有渲染代码只有一个printf不会有画面闪烁调试时也方便观察状态。2.2 用等宽字符和 ANSI 控制码控制画面比例与颜色如果你的终端默认字体不是等宽方块会长成矩形观感很怪。一个小技巧是每个格子固定占两个普通字符宽度比如用##表示一个方块。这样横竖比例接近正方形画面更接近真实俄罗斯方块。等基础画面跑通之后可以给不同方块加颜色。终端颜色用 ANSI 转义序列实现背景色比前景色更适合做方块因为字符本身可以用空格直接用色块拼出画面#define RESET \033[0m #define BG_RED \033[41m #define BG_CYAN \033[46m #define BG_YELLOW \033[43m // 输出一个红色背景的空格对 printf(BG_RED RESET);注意这种写法依赖终端对 ANSI 转义的支持。Windows 10 以上的新终端、Linux、macOS 的 Terminal 基本都能正常处理老版本控制台可能输出←[41m这样的乱码。所以代码里最好留一个开关#define ENABLE_COLOR 1 #if ENABLE_COLOR printf(BG_RED RESET); #else printf(##); #endif一套渲染逻辑两套表现遇到不支持颜色的环境不至于不能用。2.3 清屏、复位光标和跨平台的渲染细节很多教程喜欢用system(cls)清屏我建议换一种方式。清屏之后终端整个闪一下眼睛难受而且每次刷新还要重新输出所有内容速度更慢。更好的办法是用printf(\033[H)把光标挪回左上角然后直接覆盖旧的画面输出新帧。这样输出量最小也没有闪烁。Linux 和 macOS 下完全没问题。Windows 下面如果 ANSI 支持有问题只能用system(cls)兜底这个可以写在条件编译里#ifdef _WIN32 system(cls); #else printf(\033[H); #endif在写这个项目时跨平台差异远不止渲染一处下一节的输入处理才是真正的分水岭。3. 按一个键马上生效非阻塞输入才是游戏手感的分水岭画面能动起来之后你会发现键盘完全没反应。原因是 C 标准库里的getchar()、scanf()默认工作在“规范模式”要等用户按回车才把一整行数据交给程序。游戏需要的是“按一个方向键立刻处理一个动作”必须让键盘进入非阻塞模式。这一步是终端游戏和普通控制台程序最大的分水岭。处理不好游戏毫无手感处理好游戏才真正“能玩”。3.1 Windows_kbhit 与 _getch 的方向键读取Windows 下最简单的方式是利用conio.h头文件里的两个函数_kbhit()检测键盘缓冲区里有没有按键有返回非 0没有返回 0不会阻塞。_getch()读取一个按键不回显。组合起来就是一个非阻塞输入函数#include conio.h int read_key() { if (!_kbhit()) { return KEY_NONE; } int ch _getch(); // 方向键是两个字节第一个是 224 或 0 if (ch 224 || ch 0) { ch _getch(); switch (ch) { case 72: return KEY_UP; case 75: return KEY_LEFT; case 77: return KEY_RIGHT; case 80: return KEY_DOWN; } } else { // 单字节键 switch (ch) { case : return KEY_HARD_DROP; case p: case P: return KEY_PAUSE; case q: case Q: return KEY_QUIT; } } return KEY_NONE; }这里最坑的是方向键。按一次←_getch()会先返回一个 224有些键盘是 0紧接着才返回方向码72 上、75 左、77 右、80 下。如果只读一次就结束你会拿到 224 这个没用的值。还有一个细节连续快速按键时键盘缓冲区会积压好几条输入。建议用一个循环把一次渲染内的按键都读完否则按一下方向键游戏会移动好几格然后卡半秒体验很差。处理方式是在游戏主循环里这样写while (read_key() ! KEY_NONE) { // 处理按键 }3.2 Linux/macOS 下的 termios 原始模式读取Linux 和 macOS 没有conio.h可用需要自己把终端切到原始模式。核心操作是修改termios结构体关掉两件事ICANON规范模式和ECHO输入回显。#include termios.h #include unistd.h #include fcntl.h static struct termios old_term; void set_raw_mode(int on) { if (on) { tcgetattr(STDIN_FILENO, old_term); struct termios raw old_term; raw.c_lflag ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, raw); // 设置非阻塞读 int flags fcntl(STDIN_FILENO, F_GETFL); fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); } else { tcsetattr(STDIN_FILENO, TCSANOW, old_term); int flags fcntl(STDIN_FILENO, F_GETFL); fcntl(STDIN_FILENO, F_SETFL, flags ~O_NONBLOCK); } }方向键在 Linux 终端里同样是多字节序列只不过不是 224 码而是 ESC 开头的一组ESC [ A表示上ESC [ B表示下ESC [ C右ESC [ D左。读取时先 read 到一个字节如果是 ESC十六进制 0x1B再继续 read 两个字节才能判断方向。一个简单的读取函数可以是int read_key() { char c; int n read(STDIN_FILENO, c, 1); if (n 0) { return KEY_NONE; } if (c 0x1B) { // ESC char seq[2]; if (read(STDIN_FILENO, seq[0], 1) 0) { return KEY_NONE; } if (read(STDIN_FILENO, seq[1], 1) 0) { return KEY_NONE; } if (seq[0] [) { switch (seq[1]) { case A: return KEY_UP; case B: return KEY_DOWN; case C: return KEY_RIGHT; case D: return KEY_LEFT; } } return KEY_NONE; } if (c ) return KEY_HARD_DROP; if (c p || c P) return KEY_PAUSE; if (c q || c Q) return KEY_QUIT; return KEY_NONE; }注意ESC 开头也可能是用户按了 ESC 键这时后续没有[ A这样的序列函数要能安全返回KEY_NONE不能卡住。3.3 千万别在退出前忘记恢复终端设置这一条必须单独拿出来强调。Linux/macOS 下改完termios之后如果程序直接退出而不恢复后果是你的终端从这以后不再回显输入敲任何命令都看不到自己打了什么按回车还会出现奇怪的^M。不是系统坏了是终端的原始模式没还回去。所以set_raw_mode(0)必须在所有退出路径上被调用。最简单的做法是在main()结尾、return之前调用。但如果你在游戏里按q直接return或者用 CtrlC 中断就需要清理。我之前自己写的时候就在一个“按 q 退出”的分支里漏掉了恢复结果整节课都在跟同行解释怎么把终端救回来。稳妥一点的方案是用atexit()注册一个退出函数#include stdlib.h void cleanup() { set_raw_mode(0); } int main() { atexit(cleanup); set_raw_mode(1); // ... 游戏循环 return 0; }这样无论函数从哪儿return都会先调用cleanup把终端恢复成正常模式。4. 旋转、碰撞、消行把规则写成不出 bug 的代码输入能用了画面能动了游戏真正的“规则引擎”才开始。很多人把俄罗斯方块的规则代码写得绕来绕去其实核心就三件事碰撞检测、旋转时的移位处理、落定后消行。每一件都不复杂只是边界条件必须想清楚。4.1 碰撞检测的三个边界条件和两个易错点碰撞检测的逻辑很简单拿着当前方块的 4×4 矩阵逐个格子判断如果这个格子在棋盘边界外或者已经和 board 中的已有格子重叠就说明不能放。int collides(int shape, int rot, int x, int y) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (!SHAPES[shape][rot][r][c]) { continue; } int nx x c; int ny y r; // 左右越界 if (nx 0 || nx COLS) return 1; // 底部越界 if (ny ROWS) return 1; // 与已有方块重叠忽略顶部的非法区域 if (ny 0 board[ny][nx]) return 1; } } return 0; }第一个易错点顶部边界不能参与碰撞判断。新方块生成时往往一半在屏幕外面ny可能是负数。如果把ny 0也当成碰撞方块一出生就报 Game Over。正确做法是对ny 0才检查 boardny 0时只检查左右边界。第二个易错点x 最小可以等于 -2。因为旋转后的方块矩阵左侧可能空出很多列方块的“可见部分”可能从第 2 列开始。如果只判断x 0就误杀方块贴左墙时永远转不了身。必须逐个遍历矩阵中的 1 格子再换算到棋盘坐标去判断不能只查 anchor 点左上角。判断新方块是否一出生就卡死也用它if (collides(shape, rot, x, y)) { game_over 1; }4.2 旋转撞墙了怎么办简化版“墙踢”旋转本身只是一条rot 1。可问题在于方块贴墙时矩阵一变格子很可能伸到墙外面。如果你直接拒绝旋转玩家会发现在墙边完全转不动游戏非常别扭。专业俄罗斯方块里有一套 SRSSuper Rotation System踢墙表处理各种特殊位置的旋转。简单小游戏不需要那么精确用“试偏移”法就够先原地旋转如果碰壁就试着向左偏移 1 格、向右偏移 1 格、向左 2 格、向右 2 格哪个位置能放下就落到哪。int try_rotate(int *shape, int *rot, int *x, int *y) { int new_rot (*rot 1) % 4; int offsets[] {0, -1, 1, -2, 2}; for (int i 0; i 5; i) { int try_x *x offsets[i]; if (!collides(*shape, new_rot, try_x, *y)) { *rot new_rot; *x try_x; return 1; } } return 0; // 旋转失败保持原状 }注意这里只偏移 x不偏移 y可以避免方块贴近地面时通过旋转“钻进”已固定方块里。真实 TGMTetris: The Grand Master规则要复杂一些但小游戏做成这样已经足够灵活。这段逻辑如果写错最常见的现象是方块在左墙边旋转时原地不动或者旋转后穿进墙里。用上面的 offsets 方案基本不会出这两种问题。4.3 落定、消行与 Game Over 的顺序方块下落的是这样推进的void move_down() { if (!collides(shape, rot, x, y 1)) { y; } else { lock_block(); } }lock_block()把当前方块的 4×4 矩阵铺到 board 上然后立刻检查有没有满行需要消除void lock_block() { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (SHAPES[shape][rot][r][c]) { int ny y r; int nx x c; if (ny 0 ny ROWS nx 0 nx COLS) { board[ny][nx] 1; } } } } clear_lines(); spawn_next(); }clear_lines()从最底下的一行往上扫发现整行都是 1 就删掉把上面的行整体往下平移一行。这个平移要用memmove或双重循环不能想当然地用memcpy因为源和目标地址有重叠memcpy的行为未定义。void clear_lines() { for (int r ROWS - 1; r 0; r--) { int full 1; for (int c 0; c COLS; c) { if (!board[r][c]) { full 0; break; } } if (full) { // 上面的行向下移动一行 for (int r2 r; r2 0; r2--) { for (int c 0; c COLS; c) { board[r2][c] board[r2 - 1][c]; } } // 最上面一行清空 for (int c 0; c COLS; c) { board[0][c] 0; } // 重新检查当前行因为上面的内容被移下来了 r; } } }很多人会在消行时漏掉r。你想啊刚消完一行上面一行被移下来了当前位置马上变成了新数据如果不重新检查一次就可能漏掉连续消行的机会。然后是生成新方块。这里我习惯固定让方块从顶部往下一点的位置出现y 初始化为 -1 或 -2x 初始化为COLS / 2 - 2。生成后立刻调用一次碰撞检测void spawn_next() { shape next_shape; next_shape random_shape(); rot 0; x COLS / 2 - 2; y -1; if (collides(shape, rot, x, y)) { game_over 1; } }到这里Game Over 的条件就自然出来了新方块出生即碰撞说明整个地图已经堆到顶部。5. 主循环架构、计分规则与后续进化方向规则都齐了游戏能不能“玩”得舒服取决于主循环写得好不好。很多人写终端游戏最直观的做法是无限循环 每次都下落一格结果电脑性能不同方块速度忽快忽慢。这里的问题在于把渲染和重力计时混在了一起。5.1 帧循环和重力计时分开游戏才不会“变速”正确的做法是把“每帧要做的事”和“每隔多少毫秒要做的事”分开。无论容器怎么循环下落一步都需要由专门的计时器触发。C 语言里可以用clock()long now_ms() { return (long)(clock() * 1000 / CLOCKS_PER_SEC); }然后在主循环里维护一个last_move_ms变量long last_move now_ms(); while (!game_over) { int key read_key(); // 处理按键…… long cur now_ms(); if (cur - last_move fall_interval) { move_down(); last_move cur; } render(); }这样渲染帧率再高方块下落还是严格按照fall_interval来。不同电脑上跑速度表现一致手感稳定。主循环还可以控制一个基础帧率避免 CPU 空转。比如一帧至少 16ms剩余时间用usleep()补上long frame_start now_ms(); // ... 处理渲染 long elapsed now_ms() - frame_start; if (elapsed 16) { usleep((16 - elapsed) * 1000); }运行中你会发现这样有个额外的好处按键轮询集中在每帧开始时游戏不会因为终端缓冲区堆积而连续移动多格。5.2 简单可调的计分与升级规则计分不需要复杂但四行连消Tetris的奖励必须高否则玩家没有积累长条的动机。常用的规则是消除行数得分1100230035004800在clear_lines()里统计每次消了多少行然后按行数查表加分。升级可以用“每消 10 行升一级”的规则等级提升后缩短下落间隔int level lines_cleared / 10 1; int fall_interval 1000 - (level - 1) * 80; if (fall_interval 80) { fall_interval 80; // 保持最低下限 }这套规则的好处是简单、好调而且玩家能立刻感知到“消了四行有惊喜”。你完全可以根据自己的手感调整初始间隔和梯度。比如我更喜欢初始 800ms、最低 60ms不同机型上测试一下就知道差距。5.3 代码模块拆分与终端游戏的调试技巧到这一步所有逻辑都已经跑通了。如果代码全堆在main.c里可能已经 500 行了后面想加个 NEXT 预览、存个最高分都会很痛苦。我建议按下面的方式拆文件shapes.h / shapes.c // 方块预置数据随机生成逻辑 board.h / board.c // 棋盘状态、碰撞检测、消行 input.h / input.c // 键盘读取跨平台差异都收在这里 game.h / game.c // 主循环、状态管理、计分 main.c // 入口调用 game_start()这样拆完最直接的好处是你想要换成图形界面时只需要改 input 和 render 两个接口核心碰撞与消行逻辑完全不用动。再分享一个终端游戏独有的调试技巧用printf打日志经常看不到输出因为 stdout 默认是行缓冲的程序崩溃时缓冲区的数据全部丢失。这时要把调试信息打到stderrfprintf(stderr, [dbg] shape%d rot%d pos(%d,%d)\n, shape, rot, x, y);stderr默认是无缓冲的能第一时间刷到终端上。我自己在写旋转逻辑时就靠这个输出逐帧看方块坐标才把“旋转后偏移一列”的老 bug 揪出来。另一个实用的调试手段是加一个慢速模式。程序启动时如果传入参数--slow就把fall_interval调成 2000ms或者干脆按一下键才走一格。这样碰撞、消行、GAME OVER 的触发过程可以看得很清楚定位问题快得多。关于后续扩展我的实际操作顺序建议这个终端版本已经可以完整地玩一局了。接下来你想继续练手的话我建议按这个顺序扩展第一给四种方块加不同颜色。渲染部分改动很少但视觉效果立刻上一个档次。第二加一个 NEXT 预览窗在终端右上角画一个小方块矩阵让玩家提前规划。随机生成用“七袋随机”方式把 7 种方块洗牌后依次取取完再洗一遍能让连续几局不出长条的情况减少很多。第三把最高分写入本地文件做了一个简单的持久化存储。这些都是我在练习过程中实际体验过的延展点每个都不会太难但都能让你对 C 语言文件操作、内存管理、状态机设计有更深的理解。等到这个版本能稳定玩到 1000 分再回头看最初的 500 行代码你会发现最大的收获其实不是“会写俄罗斯方块”而是学会了一个很通用的软件结构数据模型 → 用户输入 → 状态更新 → 渲染输出。这个骨架换到贪吃蛇、飞机大战、扫雷都一样C 语言项目练手的价值就藏在这些看似简单但要亲手踩出来的细节里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑