我之前写过一篇基础的俄罗斯方块实现,这篇算是续篇,专门聊聊那些没展开讲的细节。如果你正准备用C++写俄罗斯方块,或者写完一版但总感觉手感不对、消行有bug、旋转会穿墙,那这篇内容大概率能帮上忙。我会从方块的数据结构设计、主循环调度、消行判定、渲染优化这些容易被忽略但直接影响游戏品质的环节入手,配合实际代码逐段讲清楚。
1. 这个“补充”到底补什么:先看清俄罗斯方块的核心难题
很多人看到俄罗斯方块,第一反应是“不就是个二维数组加上几个方块吗”,上手写才发现,真正麻烦的点全在后头:方块旋转之后位置怎么算、碰到边界该怎么回退、消行时数组怎么搬移才不卡顿、按键按一下会不会触发两次下落。这一节先把这些问题点破,后面的代码才有讨论的依托。
1.1 方块表示方案:坐标数组与枚举
俄罗斯方块有七种标准方块,每个方块由四个小格组成。常见的表示方式有两种:一种是硬编码每个方块的旋转形态到一个三维数组里,另一种是只存基础形状,旋转时跑坐标变换公式。我强烈推荐前者,因为代码可读性高,也方便手动调每个形状的旋转中心。
我用的表示法是这样的:
cpp复制enum class TetrominoType {
I, O, T, S, Z, J, L
};
struct Point {
int x;
int y;
};
class Tetromino {
public:
TetrominoType type;
int rotation; // 0~3,表示当前朝向
Point cells[4]; // 四个小格相对于旋转中心的坐标
Point position; // 旋转中心在棋盘上的位置
void setType(TetrominoType t) {
type = t;
rotation = 0;
// 根据类型初始化 cells 数组
// 每种类型存 base 坐标,之后旋转时用公式推算
}
};
这里的 position 是旋转中心,cells 存的不是棋盘绝对坐标,而是相对坐标。这样设计有一个好处:碰撞检测和旋转计算都可以统一成“相对坐标 + 旋转矩阵”,不用每个方块单独写逻辑。
具体初始化时,我会把七个方块的基准姿势统一放在一个静态数组里:
cpp复制static const Point baseCells[7][4] = {
// I
{{0, 0}, {1, 0}, {2, 0}, {3, 0}},
// O
{{0, 0}, {1, 0}, {0, 1}, {1, 1}},
// T
{{0, 0}, {1, 0}, {2, 0}, {1, 1}},
// S
{{1, 0}, {2, 0}, {0, 1}, {1, 1}},
// Z
{{0, 0}, {1, 0}, {1, 1}, {2, 1}},
// J
{{0, 0}, {1, 0}, {2, 0}, {2, 1}},
// L
{{0, 0}, {1, 0}, {2, 0}, {0, 1}}
};
这样初始化时只需拷贝对应数组,不需要每个类型单独写构造函数。代码量小,也不容易出错。
1.2 旋转中心与碰撞回滚
旋转是新手最容易写崩的地方。如果你直接对相邻坐标做 (x, y) -> (-y, x) 变换,转两三次之后方块位置会明显漂移,原因是旋转中心没有固定住。
我在项目里采用的办法是:旋转前先备份当前四个细胞的绝对坐标,做一次旋转计算,然后检测是否越界或与已固定的方块重叠。如果碰撞,尝试把方块向左或向右平移一到两格,这叫“踢墙检测”。只有所有尝试都失败,才恢复备份坐标,取消这次旋转。
cpp复制bool rotate(Tetromino& t, const Board& board) {
// 1. 备份
Point backup[4];
std::copy(std::begin(t.cells), std::end(t.cells), backup);
// 2. 旋转
for (auto& cell : t.cells) {
int nx = -cell.y;
int ny = cell.x;
cell.x = nx;
cell.y = ny;
}
// 3. 踢墙尝试
int kicks[] = {0, -1, 1, -2, 2};
for (int dx : kicks) {
if (isValidPosition(t, board, dx, 0)) {
t.position.x += dx;
return true;
}
}
// 4. 回滚
std::copy(std::begin(backup), std::end(backup), t.cells);
return false;
}
这里的 isValidPosition 要把细胞坐标加上方块位置,再去棋盘数组里查格子是否为空、是否在边界内。很多人的bug就是忘记在碰撞检测时加上 position 偏移,导致方块明明在场上却判定为越界。
关于O方块的旋转,O方块旋转后看起来完全一样,但你依然需要正常走一遍旋转流程,否则后续形态计数会乱。这一点不处理的话,后面做“交换保留”功能时会出现怪现象。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 游戏主循环设计:输入、更新、渲染三者如何同步
俄罗斯方块本质上是一个实时游戏,哪怕你写的是命令行版本,也要认真对待“主循环”。主循环里最忌讳的是把输入检测、逻辑更新、渲染清屏全部塞到一起,那样一来下落速度会受渲染耗时影响,按键也会出现严重的重复触发问题。
2.1 下落计时与帧率独立
一个合格的主循环应该做到:逻辑更新与画面刷新解耦。简单做法是记录每次循环的真实耗时,累加到下落计时器里,计时器超过当前等级对应的下落间隔时,才执行一次下落动作。
cpp复制auto lastTime = std::chrono::steady_clock::now();
while (running) {
auto now = std::chrono::steady_clock::now();
float delta = std::chrono::duration<float>(now - lastTime).count();
lastTime = now;
fallTimer += delta;
if (fallTimer >= fallInterval) {
fallTimer -= fallInterval;
moveDown();
}
processInput();
render();
}
fallInterval 不要写死,要根据等级动态计算。常见公式是每升一级,下落间隔乘以一个系数,例如 fallInterval = baseInterval * pow(0.85, level - 1)。这样能让后期节奏明显加快,但又不至于一瞬间就到底。
命令行版的 render() 里通常会调用 system("cls") 或者 clear(),这个操作非常耗时,跑一次可能就要十几毫秒。如果下落计时也放在渲染后面,就会出现一种诡异的现象:机器性能越好,方块落得越快。性能和解耦问题会搅在一起,所以务必在渲染函数之外单独跑逻辑更新。
2.2 处理键盘输入与DAS延迟
俄罗斯方块高手都知道“DAS”这个词,全称是 Delayed Auto Shift,也就是长按方向键时,第一次移动和后续重复移动之间要有延迟。如果不对输入做处理,键盘自身的重复触发会带来两种体验:要么按一下移动好几格,要么长按后连续移动间隔太短,方块直接飞到底。
我的处理方式是手动管理方向键状态,不依赖系统键盘重复:
cpp复制bool leftPressed = false;
bool rightPressed = false;
float dasTimer = 0.f;
float dasDelay = 0.16f; // 160ms 延迟
float repeatInterval = 0.04f; // 重复移动间隔 40ms
void processInput() {
// 检测按键按下/松开,更新对应 pressed 状态
if (isKeyDown(KEY_LEFT)) {
if (!leftPressed) {
moveLeft();
dasTimer = dasDelay;
} else {
dasTimer -= delta;
if (dasTimer <= 0.f) {
moveLeft();
dasTimer = repeatInterval;
}
}
}
}
这里核心思想是:按键首次触发时立刻移动一次,然后进入DAS延迟期,延迟结束后按固定间隔重复移动。把DAS延迟设置在160ms左右,重复间隔40ms,手感会比较接近经典俄罗斯方块。
软降(Soft Drop)也值得单独讲一下。长按下方向键时,方块以比自动下落快得多的速度下落。我习惯把软降速度设为自动下落速度的20倍,但要注意软降是逐格移动,不要去改 fallInterval,而是每次循环都判断一次“下方向键是否按住”,按住就立即 moveDown() 并加分。这个加分是可选的,但很符合经典游戏规则:软降和硬降都有1分/格的奖励。
硬降则是直接落底并锁定。硬降时要注意先执行落底移动,再立刻锁定并生成新方块,中间不要插入渲染,否则会有一帧画面显示方块悬在半空。
3. 消行、计分与等级:数值设计背后的游戏性逻辑
消行是俄罗斯方块最核心的正反馈机制,也是新手最容易出逻辑漏洞的地方。很多人图省事,检测到一行满了就只做这一行的删除,结果出现“悬浮行”,或者一次消四行时只消了一行。
3.1 消行判定与数组搬移
我的做法是逐行扫描棋盘,把不需要消除的行保留到一个临时数组里,最后一次性搬回棋盘顶部。而不是边扫边删,后者容易在连续多行需要消除时漏掉。
cpp复制int clearLines(std::array<std::array<int, COLS>, ROWS>& board) {
int cleared = 0;
std::array<std::array<int, COLS>, ROWS> temp{};
int writeRow = ROWS - 1;
for (int r = ROWS - 1; r >= 0; --r) {
bool full = true;
for (int c = 0; c < COLS; ++c) {
if (board[r][c] == 0) {
full = false;
break;
}
}
if (!full) {
temp[writeRow] = board[r];
--writeRow;
} else {
++cleared;
}
}
board = temp;
return cleared;
}
这里用了一个临时棋盘来接收未满的行,写位置从底部开始,也就是说没有被消除的行会自然“落”到底部。新腾出来的行在最上面,默认全零。这个写法比传统的 for 循环搬移要直观得多,也不容易出现索引错乱。
值得注意的一点是:清行时不要实时加分,应该先判定本次下落消了几行,再按行数进入统一的计分逻辑。因为消一行和消四行的分数差距很大,分开处理会破坏连击逻辑的一致性。
3.2 计分公式与等级节奏控制
经典计分标准是:消1行100分,2行300分,3行500分,4行800分。奖励级数会随当前等级放大,也就是 score = basePoints * level。
连击系统处理可以做得简单一些:记录玩家最近一次消行到现在的下落次数,如果连续下落若干次后再次消行,则视为连击,额外增加奖励分。不是每个版本都需要这个系统,但加上去之后游戏的爽感明显提升。
等级提升的条件也要配合下落速度来设计。常见的方案是总分每满一定阈值就升一级,也可以设计成消行数达到10行升级。我更推荐前者,因为玩家可以明确感知到“分数冲得越快,难度爬得越高”,对局节奏更有压迫感。
要特别注意的是,升级之后不要立刻重置下落计时器为0。否则会有一个很别扭的体验:升级瞬间下落计时被清掉,方块在半空静止几帧,仿佛卡了一下。正确的做法是保留当前计时器余额,只替换下落间隔,这样方块在下落过程中的加速度是连续的。
4. 渲染与用户体验:命令行版和图形版各自的坑
很多人的俄罗斯方块止步于“能玩”,但想让项目拿得出手,渲染层面的优化绕不开。这一节分为两块:如果你用传统控制台,怎么减少闪烁;如果你用Windows图形接口或者SDL,怎么处理好下一个方块预览和交换保留。
4.1 双缓冲与重绘:解决命令行闪烁
命令行版俄罗斯方块最常见的毛病就是闪烁,原因是每次重绘前清屏,然后又逐行输出棋盘,在快速下落时画面会剧烈跳动。解决思路是不要清屏,而是用光标定位的方式覆盖写。
在Windows上可以用 SetConsoleCursorPosition 把光标移到指定行和列,然后直接输出空格或方块字符。这样每次渲染只更新变化区域,画面会流畅很多。本质上就是手动实现一个控制台版的双缓冲。
cpp复制void gotoxy(int x, int y) {
COORD pos = { static_cast<SHORT>(x), static_cast<SHORT>(y) };
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos);
}
void renderCell(int row, int col, char ch) {
gotoxy(col * 2 + OFFSET_X, row + OFFSET_Y);
std::cout << ch << ch;
}
用 gotoxy 还有个好处:你可以在棋盘旁边固定输出分数、等级、下一个方块,数据更新时只需要改那几行,不会影响棋盘区的显示。这比一帧一帧重画全屏干净得多。
记住一个细节:控制台光标默认是可见的,而且会随着 cout 输出闪烁跳动,影响观感。渲染前记得调用 SetConsoleCursorInfo 把光标隐藏掉。
4.2 下一个预览与交换保留
预览下一个方块属于体验型的加分项,实现起来并不难,但要在数据结构上留好接口。我习惯用一个 queue<TetrominoType> 保存即将出现的方块序列,每次生成新方块时从队列里取队首,通过一个随机算法往队尾补充新方块。理论上经典俄罗斯方块要求7种方块各出现一次再重排,这个叫“7-bag随机”,可以保证玩家不会连续拿到同一个方块。
交换保留(Hold)功能就更进阶了。核心逻辑是:玩家把当前块放进保留区,同时把保留区之前存放的块换出来变成当前块。需要注意的是,这个功能通常是两回合限制一次,也就是说交换之后,必须等方块固定一次之后才能再次交换。如果不加这个限制,玩家会在危险局面下反复横跳,游戏难度直接崩掉。
实现时只需要在逻辑更新之前检查交换键是否被按下,然后做一次 swap(currentType, holdType),再重新初始化当前方块的形状和位置即可。注意位置要重置到出生点,否则会出现新方块卡在场地边缘的错觉。
5. 常见问题与调试技巧:真实踩坑记录
我写了多版俄罗斯方块,有些bug隔了几个月再次写还是会犯。这一节整理几个高频问题和对应的排查手段,实测下来的确是排查效率最高的方案。
5.1 典型bug清单与快速定位思路
最能坑人的问题之一是游戏结束判定逻辑:新方块生成时,出生点已经和场内的方块重叠。很多人的做法是直接结束,但这个判定需要放在方块生成之后、渲染之前,不能放在下落逻辑里。否则玩家看到方块还没落完,游戏就突然结束了。
另一个高频bug是旋转踢墙时把方块踢到了别人身上。调试时我习惯把棋盘状态打印成一个二维数组,在其中标出当前方块覆盖的格子,用符号区分“已固定方块”、“当前方块”、“空位”。这样每次旋转或移动后,一眼就能看出碰撞路径上发生了什么事情。
还有左边界和右边界不对称的问题。很多人的碰撞检测只检查右边是否越界,或者把边界判定写在移动函数里面而不是统一的合法性检测里。结果是方块可以贴着左墙走,但到了右墙就卡住。所有位置合法性判断都应该走同一个函数,这个函数统一考虑左右墙和底面边界。
这里给出一个调试用的棋盘打印函数框架,实际排查时非常管用:
cpp复制void debugPrint(const Board& board, const Tetromino& t) {
for (int r = 0; r < ROWS; ++r) {
std::string line;
for (int c = 0; c < COLS; ++c) {
if (t.contains(r, c)) {
line += "[]";
} else if (board[r][c] != 0) {
line += "##";
} else {
line += "..";
}
}
std::cout << line << '\n';
}
}
把当前方块和固定方块分开标记后,跑几个边界用例即可验证碰撞正确性。优先级从高到低依次是:旋转合法性、移动合法性、落底判定。
5.2 卡方块与穿墙的排查思路
方块穿墙的常见根因是更新坐标的顺序不对。比方说你先修改了 position.x,再用旧的 cells 去做碰撞检测,那肯定不准确。正确顺序应该是:先把要应用的变化计算到一份临时坐标里,然后基于临时坐标检测,检测通过再写入正式坐标。
我推荐在移动和旋转函数里统一遵守这个模式:
cpp复制bool tryMove(Tetromino& t, const Board& board, int dx, int dy) {
Point newPos = { t.position.x + dx, t.position.y + dy };
if (isValidAt(t, board, newPos)) {
t.position = newPos;
return true;
}
return false;
}
newPos 是临时的,只有检测合法才提交。这样从根上避免了“移动到一半卡进方块里”的状态。
还有一类问题非常隐蔽:旋转后允许踢墙,但踢墙的目标位置没有重新检测与场内固定方块的碰撞。这就导致方块虽然没越界,却穿过了固定方块。踢墙检测必须是“旋转后新位置 + 水平偏移”整体做一次合法性判定,每一档偏移都要完整检测,不能只检测越界就放行。
5.3 vscode配置C++环境的小提示
很多读者会搜“vscode配置c/C++环境”,这里顺带提一句。写这种小游戏项目,不需要额外的第三方库,基本的编译调试环境就够了。
在vscode里配置C++开发环境通常包括:
- 安装C/C++扩展,这个扩展同时提供 IntelliSense 和调试支持。
- 配置
launch.json,指定调试程序路径为你的可执行文件。 - 配置
tasks.json,设置编译命令,编译器建议用g++,命令大致是g++ -g main.cpp -o tetris.exe。 - 设置
MiDebuggerPath指向gdb的可执行文件,方便设置断点。
如果你在Windows上用MinGW,需要确保 g++ 和 gdb 都在环境变量里,否则调试器会报错。右键调试时如果提示“无法找到调试器”,第一时间检查这个路径配置。
调试俄罗斯方块这类游戏,我比较推荐用“条件断点”:在消行函数入口打断点,条件设成 cleared > 0,这样只有在消行触发时才会中断,避免每帧都停。
6. 从能玩到好玩:几个值得加上去的进阶系统
如果你的基础版本已经稳定,不妨考虑加入下面这些扩展,它们能让项目在功能和代码结构上都更完整,也适合作为简历项目或者课程设计的加分项。
6.1 下一个方块队列和7-bag随机算法
经典俄罗斯方块不会完全随机出块,而是把七种方块打乱后依次发完,再重新打乱。这个策略就是“7-bag”,它可以显著减少连续出同一种方块的极端情况。
实现思路:维护一个 std::vector<TetrominoType> 当队列,当队列长度小于某个阈值时,把打乱的7种方块追加进去。可以用 std::shuffle 配合随机数引擎来打乱。
这个系统会让玩家对后续节奏有预期,也方便后期加入“看三步”的UI显示。
6.2 硬降、软降的分数反馈与音效提示
很多入门项目没有分数反馈,玩家硬降后没有任何信号,体验非常闷。你可以在硬降触发后输出一行提示信息,或者显示“+2”这样的浮空数字,虽然控制台实现浮空数字比较麻烦,但至少可以做一个“本次硬降得分”的提示。
音乐音效这块,如果继续用控制台,可以用Windows的 Beep 函数模拟简单的提示音。消行和旋转用不同频率的蜂鸣声,反馈感会强很多。注意 Beep 会阻塞程序,不要在游戏主循环里直接调用,最好放到一个单独线程里,或者干脆不阻塞播放。
6.3 状态机驱动的界面流程
游戏应该区分“主菜单”、“游戏中”、“暂停中”、“结束”几种状态。用枚举值保存当前状态,然后在主循环里根据状态分发逻辑,而不是用一堆 if 散落在各处。
cpp复制enum class GameState {
Menu,
Playing,
Paused,
GameOver
};
GameState state = GameState::Menu;
void update(float delta) {
switch (state) {
case GameState::Menu:
// 显示菜单、等待按键开始
break;
case GameState::Playing:
// 下落、输入、消行判定
break;
case GameState::Paused:
// 只检测继续键
break;
case GameState::GameOver:
// 显示分数、等待重新开始
break;
}
}
状态机的好处是所有流程都集中在一个入口,后续加回放、加选关卡、加难度模式,都是往里加状态,不需要大改主循环。这也是C++小游戏项目里很值得练手的设计模式。
一些实际操作后的心得
俄罗斯方块这个项目麻雀虽小,五脏俱全。真要写好,牵扯到数据结构选型、碰撞检测、输入管理、渲染优化、游戏平衡设计等多个维度。很多人在网上找源代码,跑起来之后感觉“看一眼就会了”,但自己写一遍才发现各种边界条件防不胜防。
我个人最大的体会是:先把数据结构做对,再谈玩法。如果你的方块存储方式合理、碰撞检测函数统一,后面加功能会非常顺畅;反之,数据结构一乱,每加一个系统都要连带改前面所有逻辑,那才是真正的噩梦。
调试方面也多说一句:像这种带状态和时间驱动的程序,不要靠猜。把关键变量打印出来,把状态变化记录在案,往往几分钟就能定位到问题。写完一版之后,建议拿经典规则逐条验证——旋转踢墙、消四行、软降计分、交换冷却——每一个都走一遍测试用例,能有效避免交上去的代码在演示现场翻车。
