博客:如何实现方块旋转

来自俄罗斯方块中文维基
这是一篇博客。博客作者是 用户:Aqua6623,发表于 2025年5月29日。
博客的著作权默认完全归作者所有,除非另有声明。未经作者授权,禁止修改或转载。
这篇博客涉及的代码均以 Lua 的格式写出,因为这篇博客的作者 LOVE2D 入脑了。
OI 人别给我看到一半就瞎逼逼,这篇博客不是给你们这些算法复杂度和算法效率魔怔人看的。我非常讨厌你们这些人对我的想法指手画脚。

方块旋转有好多种实现方法,各种方法各有千秋,开发者可以根据需要权衡选择。

其中两种方法最常用,一种是存着所有方块的旋转态,另一种是直接对方块应用数学计算从而生成新的旋转后的方块。

很显然的方块如何旋转又涉及到方块如何存储。所以先从方块的存储格式开始说起。

如何存储一个方块?

有两种符合直觉的方块存储方式:直接使用二维列表(矩阵形式)记录每个小格的坐标(坐标形式)

矩阵形式

矩阵形式非常直观,大多数方块游戏都使用矩阵形式存储方块。

一般可以直接使用一个能框住方块的最小正方形作为方块矩阵,这样可以自然确定旋转中心,不需要添加额外的偏移。

使用最小外包框大小的列表存储也可行,Techmino 就是这么做的。但这样做势必要定义更多数据对方块旋转中产生的偏移加以修正,略显繁琐。

例如:

ZZTet.png
Tet.pngZZ
Tet.pngTet.pngTet.png
Tet.pngSS
SSTet.png
Tet.pngTet.pngTet.png
JTet.pngTet.png
JJJ
Tet.pngTet.pngTet.png
Tet.pngTet.pngL
LLL
Tet.pngTet.pngTet.png
Tet.pngTTet.png
TTT
Tet.pngTet.pngTet.png
OO
OO
Tet.pngTet.pngTet.pngTet.png
IIII
Tet.pngTet.pngTet.pngTet.png
Tet.pngTet.pngTet.pngTet.png
Z={
    {'Z','Z',' '},
    {' ','Z','Z'},
    {' ',' ',' '},
}
O={
    {'O','O'},
    {'O','O'},
}
I={
    {' ',' ',' ',' '},
    {'I','I','I','I'},
    {' ',' ',' ',' '},
    {' ',' ',' ',' '},
}

显然的,列表中的 'Z' 'O' 'I' 等代表小格,而 ' ' 代表空。

坐标形式

另一种方法是记录所有小格的相对坐标。这种方法同样隐式定义了旋转中心,其相对位置即是 (0,0)。换句话说,这样存储的就是以旋转中心为原点的绝对坐标。

这是 Aquamino 使用的存储格式。

例如:

Z={
    { 0, 0},{ 1, 0},{ 0, 1},{-1, 1}
}
O={
    { 0.5, 0.5},{ 0.5,-0.5},{-0.5,-0.5},{-0.5, 0.5}
}
I={
    {-1.5, 0.5},{-0.5, 0.5},{ 0.5, 0.5},{ 1.5, 0.5}
}

也许你已经注意到了这里的 O 和 I 使用浮点数存储,在部分语言里需要额外的方法兼容,例如在碰撞判定中使用取整函数将其转化为整数再进行进一步判定。

但是,一些语言,例如 Lua(5.1 版本及 LuaJIT),实际上并没有整数与浮点数的区分,有且只有双精度浮点数。这种情况下就无需担心兼容问题了。

如何让方块转起来?

前面已简要描述两种方块存储的格式,现在来到博客的主题:旋转。

旋转同样有两种方法实现:存储方块所有的旋转态,以及对小格的坐标进行数学运算。对于后者,学过线性代数的读者应当比较熟悉,这样做等价于使用一个矩阵,对方块应用一个线性变换,这里就是旋转。

存储所有旋转态

这种方法相对无脑一些,搭配上矩阵形式的方块则更显直观,同样是很多方块游戏实现旋转的方法。该法也能直接模拟一些 ZSI 三块为二态的旋转系统。

这里以 0123…… 为旋转态编号,要旋转时,顺时针 +1,逆时针 -1……最终取模即得到最终旋转态。

例如:

Tet.pngTet.pngTet.pngTet.png
IIII
Tet.pngTet.pngTet.pngTet.png
Tet.pngTet.pngTet.pngTet.png
Tet.pngTet.pngITet.png
Tet.pngTet.pngITet.png
Tet.pngTet.pngITet.png
Tet.pngTet.pngITet.png
Tet.pngTet.pngTet.pngTet.png
Tet.pngTet.pngTet.pngTet.png
IIII
Tet.pngTet.pngTet.pngTet.png
Tet.pngITet.pngTet.png
Tet.pngITet.pngTet.png
Tet.pngITet.pngTet.png
Tet.pngITet.pngTet.png
OO
OO
ZZTet.png
Tet.pngZZ
Tet.pngTet.pngTet.png
Tet.pngTet.pngZ
Tet.pngZZ
Tet.pngZTet.png
Tet.pngTet.pngTet.png
ZZTet.png
Tet.pngZZ
Tet.pngZTet.png
ZZTet.png
ZTet.pngTet.png
GZGZTet.png
Tet.pngGZGZ
Tet.pngTet.pngTet.png
Tet.pngTet.pngGZ
Tet.pngGZGZ
Tet.pngGZTet.png
I={
    [0]={
        {' ',' ',' ',' '},
        {'I','I','I','I'},
        {' ',' ',' ',' '},
        {' ',' ',' ',' '},
    },
    [1]={
        {' ',' ','I',' '},
        {' ',' ','I',' '},
        {' ',' ','I',' '},
        {' ',' ','I',' '},
    },
    [2]={
        {' ',' ',' ',' '},
        {' ',' ',' ',' '},
        {'I','I','I','I'},
        {' ',' ',' ',' '},
    },
    [3]={
        {' ','I',' ',' '},
        {' ','I',' ',' '},
        {' ','I',' ',' '},
        {' ','I',' ',' '},
    },
}
O={
    [0]={
        {'O','O'},
        {'O','O'},
    },
}
Z={
    [0]={
        {'Z','Z',' '},
        {' ','Z','Z'},
        {' ',' ',' '},
    },
    [1]={
        {' ',' ','Z'},
        {' ','Z','Z'},
        {' ','Z',' '},
    },
    [2]={
        {' ',' ',' '},
        {'Z','Z',' '},
        {' ','Z','Z'},
    },
    [3]={
        {' ','Z',' '},
        {'Z','Z',' '},
        {'Z',' ',' '},
    },
}
Z_GB={
    [0]={
        {'Z','Z',' '},
        {' ','Z','Z'},
        {' ',' ',' '},
    },
    [1]={
        {' ',' ','Z'},
        {' ','Z','Z'},
        {' ','Z',' '},
    },
}

这样,就可以像这样定义旋转函数:

function rotate(piece,ori,mode) --方块名,朝向,旋转方向
    local newOri=ori
    if     mode='CW'  then newOri=newOri+1
    elseif mode='CCW' then newOri=newOri-1
    elseif mode='180' then newOri=newOri+2
    else   error("not correct rotate mode")

    newOri=newOri%#block.piece --对newOri取模,保证取到正确旋转态

    return block[piece][newOri] --输出最终的旋转态
end

坐标形式的方块同理,这里不再赘述。

应用线性变换

这种方法就没有直接列出所有旋转态那么直观了,优点大概只是能够减少一些重复的工作量。但 Aquamino 使用的是这种方法。

首先要明白旋转如何用数学公式表达。这里以逆时针旋转为例,利用三角函数的和角公式,有:

sin(α+π/2)=cos(α)
cos(α+π/2)=-sin(α)

三角函数的值对应单位圆上一点,将 sin(α) 与 cos(α) 改写成 x 与 y,就得到:

x 变换成新的 y
y 变换成新的 -x

即 x=y1 y=-x1

最终得到 y1=x x1=-y

顺时针旋转与 180° 旋转同理,
顺时针旋转是 y1=-x x1=y
逆时针旋转是 y1=-x x1=-y

如果学过线性代数,应该知道,这样的变换属于线性变换,可以直接用矩阵描述出来:

[cosθ -sinθ]
[sinθ  cosθ]

其中 θ 代表旋转角。

这种方法比较难以适配矩阵形式的方块,因为矩阵形式下的 (0,0) 是整个正方形框的左上角一格,先要将方格在列表内的坐标转换成相对于旋转中心的坐标再转换回去,就比较麻烦了。

但坐标形式就没有这种麻烦,直接旋转就行了。

使用坐标形式的方块旋转变换函数如下:

function rotate(piece,ori,mode) --方块,朝向(额外信息),旋转方向
    local newOri=ori
    if     mode='CW'  then newOri=newOri+1
    elseif mode='CCW' then newOri=newOri-1
    elseif mode='180' then newOri=newOri+2
    else   error("not correct rotate mode")

    newOri=newOri%#block.piece

    local newPiece=copy(piece) --假设已经定义了个能复制列表的copy函数,如果要直接对piece操作,可以只输出newOri
    for i=1,#newPiece do
            --newPiece[i][1]即是newPiece里第i个方格的x坐标,newPiece[i][2]同理
        if     mode=='180' then  newPiece[i][1],newPiece[i][2]=newPiece[i][1]*-1,newPiece[i][2]*-1
        elseif mode=='CW'  then  newPiece[i][1],newPiece[i][2]=newPiece[i][2]   ,newPiece[i][1]*-1
        elseif mode=='CCW' then  newPiece[i][1],newPiece[i][2]=newPiece[i][2]*-1,newPiece[i][1]
        end
    end
    return newPiece,newOri
end