我之前写过一篇基础的俄罗斯方块实现,这篇算是续篇,专门聊聊那些没展开讲的细节。如果你正准备用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.po
