C语言俄罗斯方块实战:从二维数组到游戏循环的完整实现

发布时间:2026/10/25 14:13:06
C语言俄罗斯方块实战:从二维数组到游戏循环的完整实现 简介这是一份面向C语言初学者与Windows编程入门者的俄罗斯方块完整源码工程基于Windows API开发使用VC6.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; } #endifWindows 分支直接调_kbhit判断有没有按键有就_getch取出来没有返回 -1。Linux 分支用termios关掉ICANON行缓冲和ECHO回显设VMIN0让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每级减 50ms100mslevel1每消 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 下是否正确设置了VMIN0如果VMIN1read会阻塞等一个字符同样导致卡顿。5.3 消行后上面的方块没跟着下来现象消掉一行后上面出现一行空白方块悬空。原因消行循环方向写反了从上往下扫导致下移的行被跳过。解决改成从BOARD_ROWS - 1往 0 扫消行后r重新检查当前行见 4.2 节。这个 bug 很隐蔽因为消一行时可能看不出来消多行时才暴露。5.4 程序退出后终端输入不回显现象在 Linux 下按 CtrlC 退出程序回到 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 藏了两天才发现因为颜色干扰了肉眼判断哪些格子是空的。后来我把渲染简化成纯字符逻辑全对之后再上颜色半天就调完了。先把数据结构和循环跑对再谈体验这个顺序反过来会浪费很多时间。希望帮到你。本文还有配套的精品资源点击获取