4. 旋转与墙踢
2026/4/14大约 13 分钟
4. 俄罗斯方块——旋转与墙踢
4.1 旋转是什么?
想象你手里拿着一个T形的积木,你把它转一下,它就从"朝上"变成了"朝右"。这就是旋转——改变方块的朝向。
在上一章中,我们已经为每种方块定义了4种旋转状态。现在我们要实现的是:当玩家按下旋转键时,方块从一个旋转状态切换到下一个,同时检查旋转后的位置是否合法。
4.2 旋转的基本原理
旋转方块的逻辑分三步:
第一步:计算旋转后的新形状
↓
第二步:检查新形状在当前位置是否合法(不越界、不重叠)
↓
第三步:如果合法就旋转,如果不合法就不动(或尝试"墙踢")用大白话解释:
- 先看看旋转后方块长什么样
- 看看这个新形状能不能放在当前位置
- 能放就转,不能放就不转(或者尝试稍微挪一下再转)
C
/// <summary>
/// 尝试旋转方块
/// direction: 1 = 顺时针, -1 = 逆时针
/// 返回: 是否旋转成功
/// </summary>
public bool TryRotate(int direction, Grid grid, Piece piece)
{
// 第一步:计算新的旋转状态
int newRotation = piece.Rotation + direction;
// 确保在0-3范围内
newRotation = ((newRotation % 4) + 4) % 4;
// 获取新形状
int[,] newShape = PieceData.GetShape(piece.Type, newRotation);
// 第二步:检查新位置是否合法
if (grid.IsValidPosition(newShape, piece.GridX, piece.GridY))
{
// 第三步:合法,直接旋转
piece.SetRotation(newRotation);
return true;
}
// 不合法,返回false(后续会尝试墙踢)
return false;
}GDScript
## 尝试旋转方块
## direction: 1 = 顺时针, -1 = 逆时针
## 返回: 是否旋转成功
func try_rotate(direction: int, grid: Grid, piece: Piece) -> bool:
# 第一步:计算新的旋转状态
var new_rotation: int = piece.rotation + direction
# 确保在0-3范围内
new_rotation = ((new_rotation % 4) + 4) % 4
# 获取新形状
var new_shape = PieceData.get_shape(piece.type, new_rotation)
# 第二步:检查新位置是否合法
if grid.is_valid_position(new_shape, piece.grid_x, piece.grid_y):
# 第三步:合法,直接旋转
piece.set_rotation(new_rotation)
return true
# 不合法,返回false(后续会尝试墙踢)
return false4.3 为什么需要墙踢?
试想这个场景:你的I形长条紧贴着右边的墙壁,你想把它从横着变成竖着。但是竖着的I形需要4格宽度,而右边只有1格空间了——旋转后方块会"嵌入"墙壁,这是不合法的。
如果没有墙踢,玩家只能先往左移一格再旋转,这在紧张的游戏过程中非常不方便。
墙踢(Wall Kick) 的解决方案是:当旋转后方块不合法时,自动尝试把方块往旁边挪几个位置,看能不能找到一个合法的位置。如果能找到,就一边旋转一边移动。
场景示例:I形方块贴着右墙
旋转前(水平): 旋转后(竖直,不合法):
. . . . . . . . ■ .
. . . . . . . . ■ .
■ ■ ■ ■ . . . . ■ .
. . . . . . . . ■ .
墙踢后(往左移一格):
. . ■ . .
. . ■ . .
. . ■ . .
. . ■ . .
← 成功!4.4 SRS旋转系统
SRS(Super Rotation System,超级旋转系统) 是现代俄罗斯方块(如《俄罗斯方块指南》Tetris Guideline)使用的标准旋转系统。它定义了每种方块在每种旋转状态切换时,应该尝试哪些偏移位置。
SRS为每种方块定义了5个"测试点"——从偏移量为(0,0)开始,依次尝试这5个位置,第一个合法的就采用。
SRS偏移数据表
以下数据看起来很多,但你不需要记住它们。它们只是SRS系统预定义的"尝试位置":
| 方块 | 状态变化 | 测试偏移(从0开始尝试) |
|---|---|---|
| J/L/S/T/Z | 0→1 | (0,0), (-1,0), (-1,+1), (0,-2), (-1,-2) |
| J/L/S/T/Z | 1→0 | (0,0), (+1,0), (+1,-1), (0,+2), (+1,+2) |
| J/L/S/T/Z | 1→2 | (0,0), (+1,0), (+1,-1), (0,+2), (+1,+2) |
| J/L/S/T/Z | 2→1 | (0,0), (-1,0), (-1,+1), (0,-2), (-1,-2) |
| J/L/S/T/Z | 2→3 | (0,0), (+1,0), (+1,+1), (0,-2), (+1,-2) |
| J/L/S/T/Z | 3→2 | (0,0), (-1,0), (-1,-1), (0,+2), (-1,+2) |
| J/L/S/T/Z | 3→0 | (0,0), (-1,0), (-1,-1), (0,+2), (-1,+2) |
| J/L/S/T/Z | 0→3 | (0,0), (+1,0), (+1,+1), (0,-2), (+1,-2) |
| I | 0→1 | (0,0), (-2,0), (+1,0), (-2,-1), (+1,+2) |
| I | 1→0 | (0,0), (+2,0), (-1,0), (+2,+1), (-1,-2) |
| I | 1→2 | (0,0), (-1,0), (+2,0), (-1,+2), (+2,-1) |
| I | 2→1 | (0,0), (+1,0), (-2,0), (+1,-2), (-2,+1) |
| I | 2→3 | (0,0), (+2,0), (-1,0), (+2,+1), (-1,-2) |
| I | 3→2 | (0,0), (-2,0), (+1,0), (-2,-1), (+1,+2) |
| I | 3→0 | (0,0), (+1,0), (-2,0), (+1,-2), (-2,+1) |
| I | 0→3 | (0,0), (-1,0), (+2,0), (-1,+2), (+2,-1) |
C
/// <summary>
/// SRS(Super Rotation System)墙踢数据
/// 定义了每种方块在旋转时尝试的偏移位置
/// </summary>
public static class SRSData
{
// J/L/S/T/Z 方块的墙踢偏移数据
// WallKicks[起始状态][目标状态] = 偏移列表
private static readonly Vector2I[,] JLSTZKicks = new Vector2I[,]
{
// 0→R (0→1)
{ new(0,0), new(-1,0), new(-1,1), new(0,-2), new(-1,-2) },
// R→2 (1→2)
{ new(0,0), new(1,0), new(1,-1), new(0,2), new(1,2) },
// 2→L (2→3)
{ new(0,0), new(1,0), new(1,1), new(0,-2), new(1,-2) },
// L→0 (3→0)
{ new(0,0), new(-1,0), new(-1,-1), new(0,2), new(-1,2) }
};
// I形方块的墙踢偏移数据(偏移更大,因为I形更长)
private static readonly Vector2I[,] IKicks = new Vector2I[,]
{
// 0→R (0→1)
{ new(0,0), new(-2,0), new(1,0), new(-2,-1), new(1,2) },
// R→2 (1→2)
{ new(0,0), new(-1,0), new(2,0), new(-1,2), new(2,-1) },
// 2→L (2→3)
{ new(0,0), new(2,0), new(-1,0), new(2,1), new(-1,-2) },
// L→0 (3→0)
{ new(0,0), new(1,0), new(-2,0), new(1,-2), new(-2,1) }
};
// 反向旋转的偏移(就是正向偏移取反)
private static Vector2I GetReverseKick(Vector2I kick)
{
return new Vector2I(-kick.X, -kick.Y);
}
/// <summary>
/// 获取指定旋转的墙踢偏移列表
/// </summary>
/// <param name="type">方块类型</param>
/// <param name="fromState">旋转前状态</param>
/// <param name="toState">旋转后状态</param>
/// <returns>偏移列表</returns>
public static Vector2I[] GetWallKicks(PieceType type, int fromState, int toState)
{
fromState = ((fromState % 4) + 4) % 4;
toState = ((toState % 4) + 4) % 4;
// 确定使用哪组偏移数据
Vector2I[,] kickTable = (type == PieceType.I) ? IKicks : JLSTZKicks;
// O形方块不需要墙踢
if (type == PieceType.O)
{
return new Vector2I[] { new(0, 0) };
}
// 顺时针旋转
if ((fromState + 1) % 4 == toState)
{
return ExtractRow(kickTable, fromState);
}
// 逆时针旋转
if ((fromState + 3) % 4 == toState)
{
// 反向旋转使用反向偏移
int idx = toState;
Vector2I[] forward = ExtractRow(kickTable, idx);
Vector2I[] reversed = new Vector2I[forward.Length];
for (int i = 0; i < forward.Length; i++)
{
reversed[i] = GetReverseKick(forward[i]);
}
return reversed;
}
// 其他情况(不应该发生)
return new Vector2I[] { new(0, 0) };
}
private static Vector2I[] ExtractRow(Vector2I[,] table, int row)
{
return new Vector2I[]
{
table[row, 0], table[row, 1], table[row, 2],
table[row, 3], table[row, 4]
};
}
}GDScript
## SRS(Super Rotation System)墙踢数据
## 定义了每种方块在旋转时尝试的偏移位置
class_name SRSData
# J/L/S/T/Z 方块的墙踢偏移数据
# JLSTZ_KICKS[起始状态] = 偏移列表
const JLSTZ_KICKS: Array = [
# 0→R (0→1)
[Vector2i(0, 0), Vector2i(-1, 0), Vector2i(-1, 1), Vector2i(0, -2), Vector2i(-1, -2)],
# R→2 (1→2)
[Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, -1), Vector2i(0, 2), Vector2i(1, 2)],
# 2→L (2→3)
[Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, 1), Vector2i(0, -2), Vector2i(1, -2)],
# L→0 (3→0)
[Vector2i(0, 0), Vector2i(-1, 0), Vector2i(-1, -1), Vector2i(0, 2), Vector2i(-1, 2)]
]
# I形方块的墙踢偏移数据(偏移更大,因为I形更长)
const I_KICKS: Array = [
# 0→R (0→1)
[Vector2i(0, 0), Vector2i(-2, 0), Vector2i(1, 0), Vector2i(-2, -1), Vector2i(1, 2)],
# R→2 (1→2)
[Vector2i(0, 0), Vector2i(-1, 0), Vector2i(2, 0), Vector2i(-1, 2), Vector2i(2, -1)],
# 2→L (2→3)
[Vector2i(0, 0), Vector2i(2, 0), Vector2i(-1, 0), Vector2i(2, 1), Vector2i(-1, -2)],
# L→0 (3→0)
[Vector2i(0, 0), Vector2i(1, 0), Vector2i(-2, 0), Vector2i(1, -2), Vector2i(-2, 1)]
]
## 反向旋转的偏移(就是正向偏移取反)
static func _get_reverse_kick(kick: Vector2i) -> Vector2i:
return Vector2i(-kick.x, -kick.y)
## 获取指定旋转的墙踢偏移列表
static func get_wall_kicks(type: int, from_state: int, to_state: int) -> Array:
from_state = ((from_state % 4) + 4) % 4
to_state = ((to_state % 4) + 4) % 4
# O形方块不需要墙踢
if type == PieceData.PieceType.O:
return [Vector2i(0, 0)]
# 确定使用哪组偏移数据
var kick_table: Array = I_KICKS if type == PieceData.PieceType.I else JLSTZ_KICKS
# 顺时针旋转
if (from_state + 1) % 4 == to_state:
return kick_table[from_state].duplicate(true)
# 逆时针旋转(使用反向偏移)
if (from_state + 3) % 4 == to_state:
var forward = kick_table[to_state]
var reversed = []
for kick in forward:
reversed.append(_get_reverse_kick(kick))
return reversed
# 其他情况
return [Vector2i(0, 0)]4.5 完整的旋转与墙踢实现
现在我们把旋转和墙踢结合起来,实现完整的旋转逻辑:
C
using Godot;
/// <summary>
/// 旋转系统——处理方块的旋转和墙踢
/// </summary>
public partial class RotationSystem
{
private readonly Grid _grid;
public RotationSystem(Grid grid)
{
_grid = grid;
}
/// <summary>
/// 尝试旋转方块(带墙踢)
/// </summary>
/// <param name="piece">要旋转的方块</param>
/// <param name="direction">旋转方向:1=顺时针,-1=逆时针</param>
/// <param name="actualKick">输出参数:实际使用的墙踢偏移</param>
/// <returns>是否旋转成功</returns>
public bool TryRotateWithWallKick(
Piece piece,
int direction,
out Vector2I actualKick)
{
actualKick = Vector2I.Zero;
int fromRotation = piece.Rotation;
int toRotation = ((fromRotation + direction) % 4 + 4) % 4;
int[,] newShape = PieceData.GetShape(piece.Type, toRotation);
// 获取墙踢偏移列表
Vector2I[] kicks = SRSData.GetWallKicks(piece.Type, fromRotation, toRotation);
// 依次尝试每个偏移位置
foreach (var kick in kicks)
{
int newX = piece.GridX + kick.X;
int newY = piece.GridY - kick.Y; // 注意:Y轴取反(SRS用数学坐标系)
if (_grid.IsValidPosition(newShape, newX, newY))
{
// 找到合法位置!执行旋转
piece.SetRotation(toRotation);
piece.MoveTo(newX, newY);
actualKick = kick;
return true;
}
}
// 所有偏移都不合法,旋转失败
return false;
}
}GDScript
extends RefCounted
## 旋转系统——处理方块的旋转和墙踢
var _grid: Grid
func _init(grid: Grid) -> void:
_grid = grid
## 尝试旋转方块(带墙踢)
## 返回一个字典:{ "success": bool, "kick": Vector2i }
func try_rotate_with_wall_kick(piece: Piece, direction: int) -> Dictionary:
var from_rotation: int = piece.rotation
var to_rotation: int = ((from_rotation + direction) % 4 + 4) % 4
var new_shape = PieceData.get_shape(piece.type, to_rotation)
# 获取墙踢偏移列表
var kicks = SRSData.get_wall_kicks(piece.type, from_rotation, to_rotation)
# 依次尝试每个偏移位置
for kick in kicks:
var new_x: int = piece.grid_x + kick.x
var new_y: int = piece.grid_y - kick.y # 注意:Y轴取反
if _grid.is_valid_position(new_shape, new_x, new_y):
# 找到合法位置!执行旋转
piece.set_rotation(to_rotation)
piece.move_to(new_x, new_y)
return { "success": true, "kick": kick }
# 所有偏移都不合法,旋转失败
return { "success": false, "kick": Vector2i.ZERO }4.6 T-Spin检测
T-Spin 是俄罗斯方块中的高阶技巧,也是获得额外分数的重要手段。当一个T形方块旋转后,它的4个角中有3个或以上被其他方块(或墙壁)包围时,就判定为T-Spin。
简单来说:T形方块"挤"进了一个只有一格宽的缝隙里,靠旋转才塞进去的。
T-Spin判定规则
T形方块的四个角:
A . B
. T .
C . D
判定条件:
1. 方块刚执行过旋转
2. T形方块的4个角中,至少3个被占据(墙壁也算占据)
3. T形方块的"前方两个角"(旋转前T头指向的两个角)至少有一个被占据
T-Spin Mini(小T-Spin):
- 如果前方两个角只有1个被占据,且后方两个角都被占据C
/// <summary>
/// T-Spin检测器
/// </summary>
public static class TSpinDetector
{
/// <summary>
/// 检测是否为T-Spin
/// </summary>
/// <param name="grid">网格</param>
/// <param name="piece">T形方块</param>
/// <param name="lastKick">上次旋转使用的墙踢偏移</param>
/// <returns>TSpinType枚举值</returns>
public static TSpinType Detect(Grid grid, Piece piece, Vector2I lastKick)
{
// 只有T形方块才能触发T-Spin
if (piece.Type != PieceType.T) return TSpinType.None;
// 必须执行过旋转(有墙踢记录)
if (lastKick == Vector2I.Zero && piece.Rotation != 0)
{
// 可能是T-Spin,继续检查
}
else if (lastKick == Vector2I.Zero)
{
return TSpinType.None;
}
// 获取T形方块的四个角的坐标
// T形方块中心在 (piece.GridX + 1, piece.GridY + 1)
int cx = piece.GridX + 1;
int cy = piece.GridY + 1;
// 四个角的位置
bool cornerA = !grid.IsCellEmpty(cx - 1, cy - 1); // 左上
bool cornerB = !grid.IsCellEmpty(cx + 1, cy - 1); // 右上
bool cornerC = !grid.IsCellEmpty(cx - 1, cy + 1); // 左下
bool cornerD = !grid.IsCellEmpty(cx + 1, cy + 1); // 右下
int filledCorners = 0;
if (cornerA) filledCorners++;
if (cornerB) filledCorners++;
if (cornerC) filledCorners++;
if (cornerD) filledCorners++;
// 至少3个角被占据
if (filledCorners < 3) return TSpinType.None;
// 判断前方两个角(取决于旋转方向)
bool frontCorner1, frontCorner2;
switch (piece.Rotation)
{
case 0: // T头朝上
frontCorner1 = cornerA;
frontCorner2 = cornerB;
break;
case 1: // T头朝右
frontCorner1 = cornerB;
frontCorner2 = cornerD;
break;
case 2: // T头朝下
frontCorner1 = cornerC;
frontCorner2 = cornerD;
break;
case 3: // T头朝左
frontCorner1 = cornerA;
frontCorner2 = cornerC;
break;
default:
frontCorner1 = false;
frontCorner2 = false;
break;
}
bool frontFilled = frontCorner1 || frontCorner2;
if (frontFilled)
{
return TSpinType.Full; // 完整T-Spin
}
else
{
return TSpinType.Mini; // Mini T-Spin
}
}
}
/// <summary>
/// T-Spin类型
/// </summary>
public enum TSpinType
{
None, // 不是T-Spin
Mini, // Mini T-Spin
Full // 完整T-Spin
}GDScript
## T-Spin检测器
class_name TSpinDetector
## T-Spin类型
enum TSpinType { NONE, MINI, FULL }
## 检测是否为T-Spin
static func detect(grid: Grid, piece: Piece, last_kick: Vector2i) -> int:
# 只有T形方块才能触发T-Spin
if piece.type != PieceData.PieceType.T:
return TSpinType.NONE
# 获取T形方块的四个角的坐标
var cx: int = piece.grid_x + 1
var cy: int = piece.grid_y + 1
# 四个角的位置
var corner_a: bool = not grid.is_cell_empty(cx - 1, cy - 1) # 左上
var corner_b: bool = not grid.is_cell_empty(cx + 1, cy - 1) # 右上
var corner_c: bool = not grid.is_cell_empty(cx - 1, cy + 1) # 左下
var corner_d: bool = not grid.is_cell_empty(cx + 1, cy + 1) # 右下
var filled_corners: int = 0
if corner_a: filled_corners += 1
if corner_b: filled_corners += 1
if corner_c: filled_corners += 1
if corner_d: filled_corners += 1
# 至少3个角被占据
if filled_corners < 3:
return TSpinType.NONE
# 判断前方两个角(取决于旋转方向)
var front_corner1: bool
var front_corner2: bool
match piece.rotation:
0: # T头朝上
front_corner1 = corner_a
front_corner2 = corner_b
1: # T头朝右
front_corner1 = corner_b
front_corner2 = corner_d
2: # T头朝下
front_corner1 = corner_c
front_corner2 = corner_d
3: # T头朝左
front_corner1 = corner_a
front_corner2 = corner_c
_:
front_corner1 = false
front_corner2 = false
var front_filled: bool = front_corner1 or front_corner2
if front_filled:
return TSpinType.FULL # 完整T-Spin
else:
return TSpinType.MINI # Mini T-Spin4.7 旋转动画
旋转瞬间完成看起来会很"硬"。我们可以加一个简短的旋转动画,让方块平滑地旋转过去。
C
using Godot;
/// <summary>
/// 方块旋转动画
/// </summary>
public partial class Piece : Node2D
{
// 动画相关
private Tween _rotateTween;
private float _rotateDuration = 0.08f; // 旋转动画时长(秒)
/// <summary>
/// 带动画的旋转
/// </summary>
public void AnimatedRotate(int newRotation, Vector2I targetPosition)
{
// 计算旋转角度差
float angleDiff = (newRotation - Rotation) * 90f;
// 先更新数据
SetRotation(newRotation);
// 创建补间动画
if (_rotateTween != null)
{
_rotateTween.Kill();
}
_rotateTween = CreateTween();
// 旋转动画
_rotateTween.TweenMethod(
Callable.From((float angle) => { RotationDegrees = angle; }),
RotationDegrees - angleDiff,
RotationDegrees,
_rotateDuration
);
// 位置动画(如果有墙踢)
if (targetPosition.X != GridX || targetPosition.Y != GridY)
{
_rotateTween.Parallel().TweenProperty(
this,
"position",
new Vector2(
targetPosition.X * GameConstants.CellSize,
targetPosition.Y * GameConstants.CellSize
),
_rotateDuration
);
}
}
}GDScript
extends Node2D
# 动画相关
var _rotate_tween: Tween = null
var _rotate_duration: float = 0.08 # 旋转动画时长(秒)
## 带动画的旋转
func animated_rotate(new_rotation: int, target_position: Vector2i) -> void:
# 计算旋转角度差
var angle_diff: float = float(new_rotation - rotation) * 90.0
# 先更新数据
set_rotation(new_rotation)
# 创建补间动画
if _rotate_tween != null:
_rotate_tween.kill()
_rotate_tween = create_tween()
# 旋转动画
_rotate_tween.tween_method(
func(angle: float) -> void:
rotation_degrees = angle,
rotation_degrees - angle_diff,
rotation_degrees,
_rotate_duration
)
# 位置动画(如果有墙踢)
if target_position.x != grid_x or target_position.y != grid_y:
_rotate_tween.parallel().tween_property(
self,
"position",
Vector2(
target_position.x * GameConstants.CELL_SIZE,
target_position.y * GameConstants.CELL_SIZE
),
_rotate_duration
)4.8 本章小结
| 概念 | 说明 |
|---|---|
| 旋转 | 方块从一种状态切换到另一种状态 |
| 墙踢 | 旋转受阻时,自动尝试偏移位置 |
| SRS | 标准旋转系统,定义了每种方块的偏移测试数据 |
| T-Spin | T形方块在狭小空间中旋转的高级技巧 |
| 旋转动画 | 用Tween让旋转过程更平滑 |
关键点:
- O形方块旋转后形状不变,不需要墙踢
- I形方块的墙踢偏移比其他方块大(因为更长)
- T-Spin是高级技巧,能获得额外分数奖励
- 旋转动画使用Godot的Tween系统实现
下一章我们将实现消行检测和计分系统。
