☰
C语言俄罗斯方块实战:从二维数组到游戏循环的完整实现
2026/10/25 14:12:25 网站建设 项目流程

简介:这是一份面向C语言初学者与Windows编程入门者的俄罗斯方块完整源码工程,基于Windows API开发,使用VC++6.0编译,适合想通过小游戏实战理解窗口消息、绘图与键盘响应的读者。压缩包共28个文件,约1.12MB,包含cpp与h源文件、ico图标、rc资源脚本、dsp与dsw工程文件,以及obj、sbr、pch等编译中间产物和可执行exe,源码均带注释,便于对照学习。游戏支持左右键控制方向、下键加速、上键变形、空格暂停,逻辑完整可直接运行。作者为jingwei12345,已有4514人学习下载。读者可从中掌握Windows API下的窗口创建、消息循环、定时器刷新与方块碰撞判定等核心思路,并参考工程配置与资源组织方式,快速搭建自己的第一个Windows小游戏项目。

1. 从一块 10×20 的网格说起:C 语言俄罗斯方块到底在练什么

很多人第一次看到「C语言编写的俄罗斯方块(附源码)」这个标题,第一反应是「小游戏,抄一份跑起来就完事」。真动手写一遍才会发现,它练的不是画方块,而是三件在 C 语言基础里反复出现、却很少被讲透的事:二维数组与坐标映射、定时与输入的非阻塞协同、以及状态机驱动的游戏循环。这三件事在翁恺C语言练习题、c语言基础代码大全里都出现过,但只有放进一个完整可运行的项目里,你才会真正理解它们为什么这么设计。

这篇笔记面向两类人:刚学完 c语言基础知识入门、想找一个能跑通又能改的练手项目的新手;以及写过不少 c语言代码、但没系统整理过「终端游戏循环该怎么搭」的熟手。我会从数据结构选型讲到编译运行,再到参数调优和踩坑排查,源码结构按可直接复现的方式给出,不依赖任何图形库,纯标准 C + 终端控制,Windows 和 Linux 都能编译。读完你应该能自己从零搭出这个俄罗斯方块,并且知道每一行为什么这么写。

2. 数据结构与坐标系统:俄罗斯方块的地基怎么打

2.1 为什么用二维数组而不是链表存方块

俄罗斯方块的核心数据有两块:棋盘(board)和当前方块(tetromino)。棋盘是一个固定尺寸的网格,常见做法是 10 列 × 20 行,用int board[20][10]表示,0 表示空,非 0 表示已固定的方块颜色编号。这个选择不是随便定的——棋盘尺寸固定、访问频繁、需要按行列随机读写,二维数组的 O(1) 随机访问正好匹配,链表在这里只会增加指针开销和缓存不友好的问题。

方块本身有 7 种形状(I、O、T、S、Z、J、L),每种形状有 4 个旋转态。最省事的存法是用一个 4×4 的二维数组表示当前方块的「包围盒」,旋转就是对这个 4×4 矩阵做转置加翻转。这样每种方块只需要存一份基础形状,旋转态在运行时算出来,代码量小、不易出错。

// 棋盘:20 行 10 列,0 为空,1-7 为方块颜色编号 #define BOARD_ROWS 20 #define BOARD_COLS 10 int board[BOARD_ROWS][BOARD_COLS]; // 7 种方块的基础形状,用 4x4 包围盒表示 // 每个数字代表该格属于方块,0 代表空 static 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用static const修饰,编译期就确定,不占运行时栈空间。每个形状用 4×4 而不是紧凑的 3×3 或 2×2,是为了让旋转逻辑统一——所有形状都在同一个坐标系里转,不用为每种形状写特判。代价是包围盒里有些格子永远是空的,碰撞检测时要跳过,但这点开销可以忽略。

2.2 坐标映射:屏幕行列和数组下标怎么对应

终端里画棋盘,本质是把board[row][col]映射到屏幕的第row行、第col列。这里有个新手最容易翻车的地方:数组下标从 0 开始,但终端光标定位通常从 1 开始。如果你用 ANSI 转义序列\033[row;colH定位光标,行列都是从 1 计数的,直接拿数组下标去定位会整体偏移一格。

我一般会封装两个宏来隔离这个差异:

// 屏幕偏移:棋盘左上角在终端里的起始位置 #define OFFSET_X 2 #define OFFSET_Y 1 // 把棋盘坐标 (r, c) 转成终端光标位置并移动 #define GOTO_BOARD(r, c) printf("\033[%d;%dH", (r) + OFFSET_Y + 1, (c) + OFFSET_X + 1)

OFFSET_Y + 1和OFFSET_X + 1里的+1就是补终端从 1 计数的差。OFFSET_X留出左边距,方便后面在右侧画分数和下一个方块预览。这个映射一旦定下来,后面所有绘制、碰撞、消行都基于同一套坐标,不会出现「画的位置和判断的位置对不上」这种玄学 bug。

2.3 碰撞检测:一个函数决定方块能不能动

方块能不能左移、右移、下移、旋转,全靠碰撞检测。逻辑很直白:把方块包围盒的每个非空格子,加上当前方块在棋盘上的偏移(px, py),得到棋盘坐标,然后判断这个坐标是否越界或已被占用。

// 检测方块在 (px, py) 位置是否与棋盘冲突 // 返回 1 表示冲突(不能放),0 表示可以放 int collide(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]) continue; // 空格跳过 int br = py + r; // 棋盘行 int bc = px + c; // 棋盘列 if (bc < 0 || bc >= BOARD_COLS) return 1; // 左右越界 if (br >= BOARD_ROWS) return 1; // 触底 if (br >= 0 && board[br][bc]) return 1; // 与已固定方块重叠 } } return 0; }

注意br >= 0这个判断:方块刚生成时,包围盒顶部可能有空行,py可能是负数,此时br为负是合法的(方块还在屏幕上方),只有br >= 0时才需要查棋盘。这个细节不处理,方块生成瞬间就会误判碰撞,表现为「新方块一出来就卡死」。参数px、py是方块包围盒左上角在棋盘坐标系里的位置,旋转时只改shape不改px/py,碰撞检测负责告诉你这个旋转合不合法。

3. 游戏循环与输入处理:让方块自己往下掉

3.1 非阻塞输入:kbhit 和 getch 的跨平台坑

俄罗斯方块需要同时做两件事:方块按固定间隔自动下落,玩家按键时立刻响应。如果用scanf或getchar阻塞等输入,方块就不会自己掉了。标准 C 没有非阻塞读键盘的函数,Windows 下用<conio.h>的_kbhit()和_getch(),Linux 下需要自己用termios把终端设成非规范模式。

#ifdef _WIN32 #include <conio.h> #define KEY_READ() (_kbhit() ? _getch() : -1) #else #include <termios.h> #include <unistd.h> #include <fcntl.h> static struct termios old_term; static void term_init(void) { struct termios t; tcgetattr(STDIN_FILENO, &old_term); t = old_term; t.c_lflag &= ~(ICANON | ECHO); // 关闭行缓冲和回显 t.c_cc[VMIN] = 0; // 非阻塞读 t.c_cc[VTIME] = 0; tcsetattr(STDIN_FILENO, TCSANOW, &t); } static void term_restore(void) { tcsetattr(STDIN_FILENO, TCSANOW, &old_term); } #define KEY_READ() (getchar_or_minus1()) static int getchar_or_minus1(void) { unsigned char ch; if (read(STDIN_FILENO, &ch, 1) == 1) return ch; return -1; } #endif

Windows 分支直接调_kbhit判断有没有按键,有就_getch取出来,没有返回 -1。Linux 分支用termios关掉ICANON(行缓冲)和ECHO(回显),设VMIN=0让read立即返回。term_restore必须在程序退出前调用,否则终端会一直处于非规范模式,用户回到 shell 后输入不回显,这就是典型的「程序崩了终端也跟着坏」的血泪经验。建议用atexit(term_restore)注册,保证异常退出也能恢复。

3.2 定时下落:用时间差而不是 sleep

最朴素的做法是每次循环sleep(500)让方块掉一格,但这样按键响应也会被拖慢——你按了键,程序还在 sleep,要等它醒过来才处理。正确做法是记录上一次下落的时间,每次循环检查「现在 - 上次下落时间」是否超过阈值,超过就下落并更新时间戳。

#include <time.h> // 返回当前毫秒时间戳 long long now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL; } // 主循环里的下落逻辑 long long last_drop = now_ms(); int drop_interval = 500; // 毫秒,随等级降低 while (running) { int key = KEY_READ(); if (key != -1) handle_key(key); // 立即处理输入 long long t = now_ms(); if (t - last_drop >= drop_interval) { if (!collide(current, px, py + 1)) { py++; // 能下就下 } else { lock_piece(); // 固定到棋盘 clear_lines(); // 消行 spawn_piece(); // 出新方块 } last_drop = t; } render(); // 重绘 usleep(1000); // 1ms,避免空转吃满 CPU }

CLOCK_MONOTONIC是单调时钟,不受系统时间调整影响,比time(NULL)精度高也更可靠。drop_interval是难度参数,500ms 是入门速度,每消 10 行减 50ms,最低到 100ms 左右。循环末尾的usleep(1000)很关键——不加的话循环会以 CPU 全速空转,风扇狂转,笔记本电池掉得飞快。1ms 的休眠既不影响手感,又把 CPU 占用压到接近 0。

3.3 旋转的实现:转置加翻转,别硬编码四个状态

前面说过方块用 4×4 包围盒存,旋转就是对这个矩阵做一次转置再水平翻转。顺时针旋转的公式是new[r][c] = old[3-c][r],逆时针是new[r][c] = old[c][3-r]。这样写的好处是 7 种方块共用一套旋转代码,不用为每种形状手写四个旋转态。

// 顺时针旋转 4x4 矩阵 void rotate_cw(int src[4][4], int dst[4][4]) { for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) dst[r][c] = src[3 - c][r]; } // 处理旋转键:先试旋转,冲突就尝试左右微调(wall kick) void handle_rotate(void) { int rotated[4][4]; rotate_cw(current, rotated); // 依次尝试:原地、左移一格、右移一格、左移两格、右移两格 int kicks[] = {0, -1, 1, -2, 2}; for (int i = 0; i < 5; i++) { if (!collide(rotated, px + kicks[i], py)) { memcpy(current, rotated, sizeof(rotated)); px += kicks[i]; return; } } // 全部失败,放弃这次旋转 }

kicks数组就是所谓的「wall kick」——方块贴着墙旋转时,原地转可能越界,允许它往旁边挪一格再转。没有这个机制,玩家在边界处旋转会频繁失败,手感很差。memcpy把旋转结果写回current,注意sizeof(rotated)是 4×4×sizeof(int),别写成sizeof(int*),那是新手常犯的指针大小错误。

4. 渲染与消行:终端里画出能看的画面

4.1 双缓冲重绘:为什么不能直接清屏

最简单的渲染是每次system("clear")再全画一遍,但这样屏幕会疯狂闪烁,眼睛受不了。原因是清屏和重绘之间有一段时间屏幕是空的,人眼能感知到。解决办法是「双缓冲」:在内存里维护一份「上一帧画面」和「当前帧画面」,只把变化的格子输出到终端,没变的不动。

// 上一帧的棋盘快照,用于差异渲染 static int prev_board[BOARD_ROWS][BOARD_COLS]; void render(void) { // 先把当前方块叠加到棋盘副本上 int frame[BOARD_ROWS][BOARD_COLS]; memcpy(frame, board, sizeof(board)); for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) if (current[r][c] && py + r >= 0) frame[py + r][px + c] = current[r][c]; // 只重绘与上一帧不同的格子 for (int r = 0; r < BOARD_ROWS; r++) { for (int c = 0; c < BOARD_COLS; c++) { if (frame[r][c] == prev_board[r][c]) continue; GOTO_BOARD(r, c); if (frame[r][c]) printf("\033[4%dm \033[0m", frame[r][c]); // 带色块 else printf(" "); // 空格 } } memcpy(prev_board, frame, sizeof(frame)); fflush(stdout); // 关键:刷新缓冲区,否则画面不更新 }

这里fflush(stdout)是必须的。C 语言标准库的stdout在非交互场景下是全缓冲的,你不刷新,输出会攒在缓冲区里,表现为「程序在跑但屏幕不动」。这就是热搜里「文件缓冲区 c语言程序」常被问到的点——终端程序里printf之后要么fflush,要么输出\n触发行缓冲,但光标定位这种不带换行的输出必须手动刷。

4.2 消行逻辑:从下往上扫,别从上往下

消行的判断很简单:某一行 10 个格子全非 0,就消掉。但实现顺序有讲究——必须从下往上扫描,消掉一行后把上面的行整体下移。如果从上往下扫,消掉第 5 行后,第 6 行下移到第 5 行,但你的循环已经走到第 6 行了,会漏掉刚移下来的那行。

int clear_lines(void) { int cleared = 0; for (int r = BOARD_ROWS - 1; r >= 0; r--) { // 从下往上 int full = 1; for (int c = 0; c < BOARD_COLS; c++) { if (!board[r][c]) { full = 0; break; } } if (full) { // 把 r 以上的所有行下移一行 for (int rr = r; rr > 0; rr--) memcpy(board[rr], board[rr - 1], sizeof(board[0])); memset(board[0], 0, sizeof(board[0])); // 顶行清空 r++; // 重新检查当前行(因为上面的行移下来了) cleared++; } } return cleared; }

r++这行是精髓:消掉第r行后,原来的第r-1行移到了第r行,循环变量r自减后会跳过这一行,所以手动r++抵消,让它重新检查。这个技巧在字符串处理和数组删除里也常用,属于「不写就出 bug,写了又容易忘」的典型。memcpy的第三个参数用sizeof(board[0])而不是sizeof(board),前者是一行的字节数,后者是整个棋盘的,写错会越界拷贝,直接段错误。

4.3 分数与等级:参数怎么设才合理

分数规则各家不同,我一般用这套:消 1 行 100 分,2 行 300 分,3 行 500 分,4 行 800 分,再乘以当前等级。等级从 1 开始,每消 10 行升一级,下落间隔drop_interval = 500 - (level - 1) * 50,最低 100ms。这套参数的好处是前期节奏舒服,后期有压力但不至于完全反应不过来。

参数初始值变化规则下限
drop_interval500ms每级减 50ms100ms
level1每消 10 行加 1无上限
score_1line100乘 level无
score_4line800乘 level无

调参的时候注意drop_interval别低于 100ms,否则方块下落速度超过人眼追踪极限,玩起来就是纯运气。如果想让难度曲线更平滑,可以把「每 10 行升级」改成「每消 10 行升级但间隔减 30ms」,这样后期不会突然变难。

5. 避坑与排查:那些让俄罗斯方块跑不起来的常见问题

5.1 编译报错「无法打开源文件 conio.h」

现象:在 Linux 或 macOS 上编译,报fatal error: conio.h: No such file or directory。原因:conio.h是 Windows 特有的头文件,Linux 没有。解决:用前面 3.1 节的#ifdef _WIN32条件编译,Linux 分支改用termios.h。如果只是想在 Linux 上快速验证逻辑,也可以临时把输入处理换成阻塞式getchar,先跑通渲染和消行,再补非阻塞输入。

5.2 方块下落一顿一顿的,按键要按好几下才响应

现象:方块下落不流畅,按键延迟明显。原因:主循环里用了sleep或usleep时间过长,导致输入处理被阻塞。解决:把sleep从「每帧固定休眠」改成「循环末尾休眠 1ms」,下落靠时间差判断(见 3.2 节)。另外检查KEY_READ在 Linux 下是否正确设置了VMIN=0,如果VMIN=1,read会阻塞等一个字符,同样导致卡顿。

5.3 消行后上面的方块没跟着下来

现象:消掉一行后,上面出现一行空白,方块悬空。原因:消行循环方向写反了,从上往下扫导致下移的行被跳过。解决:改成从BOARD_ROWS - 1往 0 扫,消行后r++重新检查当前行(见 4.2 节)。这个 bug 很隐蔽,因为消一行时可能看不出来,消多行时才暴露。

5.4 程序退出后终端输入不回显

现象:在 Linux 下按 Ctrl+C 退出程序,回到 shell 后敲键盘看不到字符。原因:termios改了终端模式但没恢复。解决:用atexit(term_restore)注册恢复函数,保证正常退出和exit()调用都能恢复。如果已经被搞坏了,在 shell 里敲reset或stty sane就能救回来,这是每个写终端程序的人都该记住的后悔药。

5.5 画面闪烁严重,方块像在抖

现象:每次重绘整个屏幕闪一下。原因:用了system("clear")全屏清空再重画。解决:改用差异渲染,只重绘变化的格子(见 4.1 节)。如果还是闪,检查是不是每次printf后都fflush了——频繁fflush会增加系统调用次数,可以攒一批输出再刷一次。另外终端本身的重绘性能有限,格子别画太大,两个空格宽度一个格子就够了。

6. 进阶技巧:把俄罗斯方块改成你能拿去交作业的版本

基础版跑通之后,有几个方向可以让它更像一个「完整项目」。第一个是下一个方块预览:维护一个next_shape变量,当前方块固定后把next_shape赋给current,再随机生成新的next_shape。预览区画在棋盘右侧,用同样的GOTO_BOARD宏加偏移就行。第二个是暂停和重开:加一个paused标志,暂停时主循环只处理恢复键,不更新下落时间戳,恢复时把last_drop重置为当前时间,否则暂停久了恢复瞬间方块会连掉好几格。

第三个是存档最高分:用fopen/fprintf/fscanf把最高分写到本地文件,程序启动时读回来。这里有个缓冲区相关的坑——写文件后必须fclose或fflush,否则程序崩溃时数据还在缓冲区里没落盘。我一般会在写完后立刻fclose,不依赖程序退出时的自动刷新。

// 读写最高分 int load_highscore(void) { FILE *fp = fopen("highscore.txt", "r"); if (!fp) return 0; int score = 0; fscanf(fp, "%d", &score); fclose(fp); return score; } void save_highscore(int score) { FILE *fp = fopen("highscore.txt", "w"); if (!fp) return; fprintf(fp, "%d\n", score); fclose(fp); // 必须关闭,否则数据可能丢失 }

验证改动是否正确的办法很简单:故意在消行前后打印棋盘快照,对比消行逻辑有没有漏行;把drop_interval临时设成 2000ms,慢慢看方块每一步的行为;用gdb在collide函数下断点,单步看px、py怎么变。这些手段比盯着屏幕猜有效得多。

我自己写这个项目最大的教训是:别一上来就追求画面好看。第一版我用 ANSI 颜色和边框画得很花,结果消行 bug 藏了两天才发现,因为颜色干扰了肉眼判断哪些格子是空的。后来我把渲染简化成纯字符,逻辑全对之后再上颜色,半天就调完了。先把数据结构和循环跑对,再谈体验,这个顺序反过来会浪费很多时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询