4. 方块移动与消除
2026/4/14大约 6 分钟
休闲益智——方块移动与消除
上一章我们搭好了棋盘,现在它就像一张摆满彩色糖果的桌子——好看,但还不能玩。本章我们要让它"活"起来:玩家能交换方块,匹配的能消除,消除后能下落填充。这三步就是三消游戏的"灵魂操作"。
本章你将学到
- 处理玩家输入(点击选中和拖动交换)
- 交换两个相邻方块(含动画)
- 匹配检测算法(横向三连、纵向三连)
- 消除匹配的方块
- 消除后下落填充并生成新方块
- 处理"无效交换"(交换后没有匹配,自动还原)
输入处理:点击与拖动
三消游戏有两种操作方式:点击选中再点击交换,以及按住方块拖动方向交换。两种都实现,让玩家选择自己习惯的方式。
C
/// <summary>当前选中的方块(-1 表示没选中)</summary>
private Vector2I selectedBlock = new Vector2I(-1, -1);
/// <summary>拖动起点</summary>
private Vector2 dragStartPos;
private Vector2I dragStartGrid;
public override void _Input(InputEvent @event)
{
// 按下鼠标/触屏 → 记录起点
if (@event is InputEventMouseButton mouseDown && mouseDown.Pressed
&& mouseDown.ButtonIndex == MouseButton.Left)
{
Vector2I gridPos = ScreenToGrid(mouseDown.Position);
if (gridPos.X >= 0)
{
dragStartPos = mouseDown.Position;
dragStartGrid = gridPos;
}
}
// 释放鼠标/触屏 → 判断是点击还是拖动
if (@event is InputEventMouseButton mouseUp && !mouseUp.Pressed
&& mouseUp.ButtonIndex == MouseButton.Left)
{
Vector2I gridPos = ScreenToGrid(mouseUp.Position);
float dragDistance = mouseUp.Position.DistanceTo(dragStartPos);
if (dragDistance < 10f)
{
// 短距离 = 点击
HandleBlockClick(gridPos);
}
else
{
// 长距离 = 拖动
HandleBlockDrag(dragStartGrid, mouseUp.Position);
}
}
}GDScript
## 当前选中的方块
var selected_block: Vector2i = Vector2i(-1, -1)
## 拖动起点
var drag_start_pos: Vector2 = Vector2.ZERO
var drag_start_grid: Vector2i = Vector2i(-1, -1)
func _input(event: InputEvent) -> void:
# 按下鼠标/触屏
if event is InputEventMouseButton and event.pressed \
and event.button_index == MOUSE_BUTTON_LEFT:
var grid_pos: Vector2i = screen_to_grid(event.position)
if grid_pos.x >= 0:
drag_start_pos = event.position
drag_start_grid = grid_pos
# 释放鼠标/触屏
if event is InputEventMouseButton and not event.pressed \
and event.button_index == MOUSE_BUTTON_LEFT:
var grid_pos: Vector2i = screen_to_grid(event.position)
var drag_distance: float = event.position.distance_to(drag_start_pos)
if drag_distance < 10.0:
handle_block_click(grid_pos)
else:
handle_block_drag(drag_start_grid, event.position)判断相邻
两个方块只有"挨着"才能交换(上下左右,不含对角线)。判断相邻的方法很简单——计算它们的行差和列差之和,如果等于 1 就是相邻的。
匹配检测
匹配检测是三消游戏的核心算法。我们需要扫描整个棋盘,找出所有"三个或更多同色方块连成一线"的位置。
算法思路
想象你用手指在棋盘上从左到右、从上到下滑过:
- 横向扫描:从每一行的最左边开始,往右看,数数有几个连续同色的。如果达到 3 个或更多,就记录下来。
- 纵向扫描:从每一列的最上边开始,往下看,数数有几个连续同色的。如果达到 3 个或更多,就记录下来。
C
/// <summary>
/// 扫描整个棋盘,找出所有匹配。
/// 返回一个列表,每个元素是一组匹配的坐标。
/// </summary>
private List<List<Vector2I>> FindAllMatches()
{
var matches = new List<List<Vector2I>>();
// 横向扫描
for (int row = 0; row < Rows; row++)
{
int col = 0;
while (col < Cols)
{
PieceType type = board[row, col];
if (type == PieceType.Empty)
{
col++;
continue;
}
// 从当前位置开始,数连续同色方块
int count = 1;
while (col + count < Cols && board[row, col + count] == type)
{
count++;
}
// 3个或以上就记录
if (count >= 3)
{
var match = new List<Vector2I>();
for (int i = 0; i < count; i++)
{
match.Add(new Vector2I(row, col + i));
}
matches.Add(match);
}
col += count; // 跳过已扫描的方块
}
}
// 纵向扫描(逻辑相同,只是行和列互换)
for (int col = 0; col < Cols; col++)
{
int row = 0;
while (row < Rows)
{
PieceType type = board[row, col];
if (type == PieceType.Empty)
{
row++;
continue;
}
int count = 1;
while (row + count < Rows && board[row + count, col] == type)
{
count++;
}
if (count >= 3)
{
var match = new List<Vector2I>();
for (int i = 0; i < count; i++)
{
match.Add(new Vector2I(row + i, col));
}
matches.Add(match);
}
row += count;
}
}
return matches;
}GDScript
## 扫描整个棋盘,找出所有匹配
## 返回一个数组,每个元素是一组匹配的坐标
func find_all_matches() -> Array:
var matches: Array = []
# 横向扫描
for row in range(ROWS):
var col: int = 0
while col < COLS:
var type: int = board[row][col]
if type == 0: # 空
col += 1
continue
var count: int = 1
while col + count < COLS and board[row][col + count] == type:
count += 1
if count >= 3:
var match: Array = []
for i in range(count):
match.append(Vector2i(row, col + i))
matches.append(match)
col += count
# 纵向扫描
for col in range(COLS):
var row: int = 0
while row < ROWS:
var type: int = board[row][col]
if type == 0:
row += 1
continue
var count: int = 1
while row + count < ROWS and board[row + count][col] == type:
count += 1
if count >= 3:
var match: Array = []
for i in range(count):
match.append(Vector2i(row + i, col))
matches.append(match)
row += count
return matches消除与下落填充
找到匹配后,需要做三件事:
- 消除:把匹配的方块从棋盘上删掉(设为空)
- 下落:让上方的方块"掉下来"填补空缺
- 填充:从顶部生成新方块填满空位
这个过程可以用 Tween 动画来让方块"平滑地"移动,而不是瞬间跳过去。
C
/// <summary>
/// 消除匹配的方块,然后让上方方块下落,最后生成新方块。
/// 整个过程用动画串联,动画结束后检查是否有新的匹配(连锁)。
/// </summary>
private async Task ProcessMatches()
{
var matches = FindAllMatches();
while (matches.Count > 0)
{
// 1. 消除:把匹配的格子设为空
foreach (var match in matches)
{
foreach (var pos in match)
{
board[pos.X, pos.Y] = PieceType.Empty;
// 播放消除动画(缩小+淡出)
AnimateRemoval(pos.X, pos.Y);
}
}
// 等待消除动画播放完
await ToSignal(GetTree().CreateTimer(0.3f), SceneTreeTimer.SignalName.Timeout);
// 2. 下落:每列从下往上扫描,让方块"掉"到空位
for (int col = 0; col < Cols; col++)
{
int emptyRow = Rows - 1; // 从最下面开始找空位
// 从下往上扫描,找到非空方块就往下"掉"
for (int row = Rows - 1; row >= 0; row--)
{
if (board[row, col] != PieceType.Empty)
{
if (row != emptyRow)
{
// 把方块从 row 移到 emptyRow
board[emptyRow, col] = board[row, col];
board[row, col] = PieceType.Empty;
// 播放下落动画
AnimateFall(row, col, emptyRow, col);
}
emptyRow--;
}
}
// 3. 填充:顶部剩余的空位生成新方块
for (int row = emptyRow; row >= 0; row--)
{
PieceType newType = (PieceType)rng.RandiRange(1, TypeCount);
board[row, col] = newType;
// 播放从顶部掉入的动画
AnimateSpawn(row, col, newType);
}
}
// 等待下落动画播放完
await ToSignal(GetTree().CreateTimer(0.4f), SceneTreeTimer.SignalName.Timeout);
// 更新棋盘显示
RenderBoard();
// 4. 检查是否有新的匹配(连锁反应)
matches = FindAllMatches();
}
}GDScript
## 消除匹配的方块,然后让上方方块下落,最后生成新方块
func process_matches() -> void:
var matches: Array = find_all_matches()
while matches.size() > 0:
# 1. 消除
for match in matches:
for pos in match:
board[pos.x][pos.y] = 0 # 设为空
animate_removal(pos.x, pos.y)
await get_tree().create_timer(0.3).timeout
# 2. 下落
for col in range(COLS):
var empty_row: int = ROWS - 1
for row in range(ROWS - 1, -1, -1):
if board[row][col] != 0:
if row != empty_row:
board[empty_row][col] = board[row][col]
board[row][col] = 0
animate_fall(row, col, empty_row, col)
empty_row -= 1
# 3. 填充
for row in range(empty_row, -1, -1):
var new_type: int = rng.randi_range(1, TYPE_COUNT)
board[row][col] = new_type
animate_spawn(row, col, new_type)
await get_tree().create_timer(0.4).timeout
# 更新棋盘显示
render_board()
# 4. 检查连锁
matches = find_all_matches()完整交换流程
把上面所有的步骤串联起来,一次完整的交换操作流程如下:
关键细节
无效交换不消耗步数。只有产生了匹配的交换才算一步。这是三消游戏的行业惯例。
下一章预告
方块能交换、能消除、能下落了,但消除后没有分数,游戏就没有目标感。下一章我们将实现计分系统和连击倍率。
