4. 方块移动与交换
2026/4/14大约 12 分钟
开心消消乐——方块移动与交换
什么是方块交换?
方块交换是玩家与游戏交互的核心方式。操作流程非常简单:
- 玩家点击第一个方块,方块被"选中"(显示高亮边框)
- 玩家点击第二个相邻方块,两个方块开始交换位置
- 如果交换后产生了匹配,消除并计分
- 如果交换后没有匹配,方块回退到原位(这步不算消耗步数)
这就像你交换两本书的位置——如果交换后书架看起来更整齐了(有匹配),就保留交换;如果更乱了(没匹配),就把书放回去。
Tween 动画系统
在实现移动之前,先介绍一下 Godot 的 Tween 系统。Tween 是 Godot 内置的动画工具,可以让任何属性"平滑地"从一个值变到另一个值。
什么是 Tween?想象你在看一部动画片——角色的动作不是一帧一帧跳过去的,而是流畅过渡的。Tween 就是让数字"流畅过渡"的工具:
没有 Tween:位置从 0 瞬间跳到 100(闪烁)
有 Tween: 位置从 0 平滑地滑到 100(动画)Tween 的关键参数
| 参数 | 说明 | 比喻 |
|---|---|---|
| Duration | 动画持续时间(秒) | 走这段路要花多长时间 |
| Transition | 过渡曲线类型 | 是匀速走,还是先快后慢? |
| Ease | 缓动类型 | 是加速起步,还是减速到达? |
常用的过渡曲线:
Linear:匀速运动(像自动扶梯)Sine:正弦曲线(像秋千,自然柔和)Quad/Cubic/Quart:越来越快的加速(像弹弓)Bounce:弹跳效果(像皮球落地)Elastic:弹性效果(像橡皮筋)
点击选中方块
当玩家点击一个方块时,我们需要判断:
- 如果没有选中的方块,就选中当前点击的
- 如果已经有一个选中的方块,就尝试交换
C
/// <summary>
/// 当前选中的方块(null 表示没有选中任何方块)
/// </summary>
private Piece _selectedPiece = null;
/// <summary>
/// 处理方块被点击的事件
/// </summary>
private void OnPieceClicked(Piece clickedPiece)
{
// 如果游戏不在"等待输入"状态,忽略点击
if (_gameManager.CurrentState != GameState.Idle)
{
return;
}
if (_selectedPiece == null)
{
// 没有选中方块 → 选中当前方块
SelectPiece(clickedPiece);
}
else if (_selectedPiece == clickedPiece)
{
// 点击了已选中的方块 → 取消选中
DeselectPiece();
}
else if (IsAdjacent(_selectedPiece, clickedPiece))
{
// 点击了相邻的方块 → 尝试交换
TrySwap(_selectedPiece, clickedPiece);
}
else
{
// 点击了不相邻的方块 → 改为选中新方块
DeselectPiece();
SelectPiece(clickedPiece);
}
}
/// <summary>
/// 选中方块
/// </summary>
private void SelectPiece(Piece piece)
{
_selectedPiece = piece;
piece.ToggleSelection();
GD.Print($"[GameBoard] 选中方块: ({piece.Row}, {piece.Col})");
}
/// <summary>
/// 取消选中方块
/// </summary>
private void DeselectPiece()
{
if (_selectedPiece != null)
{
_selectedPiece.Deselect();
_selectedPiece = null;
}
}
/// <summary>
/// 检查两个方块是否相邻
/// 两个方块相邻,意味着它们的行或列相差1(但不能同时相差)
/// </summary>
private bool IsAdjacent(Piece a, Piece b)
{
int rowDiff = Mathf.Abs(a.Row - b.Row);
int colDiff = Mathf.Abs(a.Col - b.Col);
// 行差为1且列差为0,或者行差为0且列差为1
return (rowDiff == 1 && colDiff == 0)
|| (rowDiff == 0 && colDiff == 1);
}GDScript
## 当前选中的方块(null 表示没有选中任何方块)
var _selected_piece: Piece = null
## 处理方块被点击的事件
func _on_piece_clicked(clicked_piece: Piece) -> void:
# 如果游戏不在"等待输入"状态,忽略点击
if _game_manager.current_state != GameManager.GameState.IDLE:
return
if _selected_piece == null:
# 没有选中方块 → 选中当前方块
_select_piece(clicked_piece)
elif _selected_piece == clicked_piece:
# 点击了已选中的方块 → 取消选中
_deselect_piece()
elif _is_adjacent(_selected_piece, clicked_piece):
# 点击了相邻的方块 → 尝试交换
_try_swap(_selected_piece, clicked_piece)
else:
# 点击了不相邻的方块 → 改为选中新方块
_deselect_piece()
_select_piece(clicked_piece)
## 选中方块
func _select_piece(piece: Piece) -> void:
_selected_piece = piece
piece.toggle_selection()
print("[GameBoard] 选中方块: (%d, %d)" % [piece.row, piece.col])
## 取消选中方块
func _deselect_piece() -> void:
if _selected_piece != null:
_selected_piece.deselect()
_selected_piece = null
## 检查两个方块是否相邻
## 两个方块相邻,意味着它们的行或列相差1(但不能同时相差)
func _is_adjacent(a: Piece, b: Piece) -> bool:
var row_diff: int = absi(a.row - b.row)
var col_diff: int = absi(a.col - b.col)
# 行差为1且列差为0,或者行差为0且列差为1
return (row_diff == 1 and col_diff == 0) \
or (row_diff == 0 and col_diff == 1)交换动画
交换动画让两个方块同时移动到对方的位置。使用 Tween 来实现平滑的位置过渡。
C
/// <summary>
/// 尝试交换两个方块
/// </summary>
private async void TrySwap(Piece piece1, Piece piece2)
{
// 切换到"交换中"状态
_gameManager.CurrentState = GameState.Swapping;
// 取消选中状态
DeselectPiece();
GD.Print($"[GameBoard] 交换: ({piece1.Row},{piece1.Col}) ↔ ({piece2.Row},{piece2.Col})");
// 播放交换动画
await PlaySwapAnimation(piece1, piece2);
// 在数据层交换两个方块
SwapPieces(piece1.Row, piece1.Col, piece2.Row, piece2.Col);
// 检查交换后是否产生了匹配
var matches = FindMatches();
if (matches.Count > 0)
{
// 有匹配 → 消耗一步,进入匹配消除流程
_gameManager.UseMove();
_gameManager.CurrentState = GameState.Checking;
ProcessMatches(matches);
}
else
{
// 没有匹配 → 回退交换(不算步数)
GD.Print("[GameBoard] 无效交换,回退");
await PlaySwapAnimation(piece1, piece2);
SwapPieces(piece1.Row, piece1.Col, piece2.Row, piece2.Col);
_gameManager.CurrentState = GameState.Idle;
}
}
/// <summary>
/// 播放两个方块的交换动画
/// 两个方块同时移动到对方的位置
/// </summary>
/// <returns>动画完成的信号(可以 await)</returns>
private async Task PlaySwapAnimation(Piece piece1, Piece piece2)
{
// 计算目标位置
Vector2 pos1 = GridToPixel(piece1.Row, piece1.Col);
Vector2 pos2 = GridToPixel(piece2.Row, piece2.Col);
// 方块1移动到方块2的位置,方块2移动到方块1的位置
Tween tween = CreateTween();
tween.TweenProperty(piece1, "position", pos2, 0.25f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.InOut);
tween.TweenProperty(piece2, "position", pos1, 0.25f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.InOut);
// 等待动画完成
await ToSignal(tween, Tween.SignalName.Finished);
}GDScript
## 尝试交换两个方块
func _try_swap(piece1: Piece, piece2: Piece) -> void:
# 切换到"交换中"状态
_game_manager.current_state = GameManager.GameState.SWAPPING
# 取消选中状态
_deselect_piece()
print("[GameBoard] 交换: (%d,%d) ↔ (%d,%d)" % [
piece1.row, piece1.col, piece2.row, piece2.col
])
# 播放交换动画
await _play_swap_animation(piece1, piece2)
# 在数据层交换两个方块
swap_pieces(piece1.row, piece1.col, piece2.row, piece2.col)
# 检查交换后是否产生了匹配
var matches := find_matches()
if matches.size() > 0:
# 有匹配 → 消耗一步,进入匹配消除流程
_game_manager.use_move()
_game_manager.current_state = GameManager.GameState.CHECKING
_process_matches(matches)
else:
# 没有匹配 → 回退交换(不算步数)
print("[GameBoard] 无效交换,回退")
await _play_swap_animation(piece1, piece2)
swap_pieces(piece1.row, piece1.col, piece2.row, piece2.col)
_game_manager.current_state = GameManager.GameState.IDLE
## 播放两个方块的交换动画
## 两个方块同时移动到对方的位置
func _play_swap_animation(piece1: Piece, piece2: Piece) -> void:
# 计算目标位置
var pos1: Vector2 = grid_to_pixel(piece1.row, piece1.col)
var pos2: Vector2 = grid_to_pixel(piece2.row, piece2.col)
# 方块1移动到方块2的位置,方块2移动到方块1的位置
var tween := create_tween()
tween.tween_property(piece1, "position", pos2, 0.25) \
.set_trans(Tween.TransitionType.SINE) \
.set_ease(Tween.EaseType.IN_OUT)
tween.parallel().tween_property(piece2, "position", pos1, 0.25) \
.set_trans(Tween.TransitionType.SINE) \
.set_ease(Tween.EaseType.IN_OUT)
# 等待动画完成
await tween.finishedGDScript 中并行 Tween
注意在 GDScript 版本中,第二个 tween_property 前面加了 .parallel()。这是因为在 GDScript 中,Tween 默认是串行的(一个接一个执行)。加上 .parallel() 后,两个方块的移动动画会同时进行。C# 版本默认就是并行的。
下落与填充
消除之后,上面的方块需要往下掉来填补空位。这个过程分两步:
- 已有方块下落:每一列中,空位上方的方块往下移动
- 顶部填充:棋盘顶部空出来的位置,生成新的随机方块
下落逻辑
想象一列方块是一叠积木,你从中间抽走了几块,上面的积木就会因为重力往下掉。下落逻辑需要:
- 从每一列的底部开始往上扫描
- 找到空位后,把上方最近的非空方块"拉"下来
- 重复直到所有空位都被填满
C
/// <summary>
/// 处理方块下落和填充
/// 消除完成后调用此方法
/// </summary>
public async void ApplyGravity()
{
_gameManager.CurrentState = GameState.Falling;
GD.Print("[GameBoard] 开始下落填充...");
bool hasMoved = false;
// 逐列处理
for (int col = 0; col < GameManager.COLS; col++)
{
// 从倒数第二行开始往上扫描(最底行不需要下落)
for (int row = GameManager.ROWS - 2; row >= 0; row--)
{
if (_grid[row, col] == PieceType.Empty)
{
// 找到空位,往上找第一个非空的方块
for (int above = row - 1; above >= 0; above--)
{
if (_grid[above, col] != PieceType.Empty)
{
// 把上面的方块移到这个空位
MovePieceDown(above, col, row, col);
hasMoved = true;
break;
}
}
}
}
// 填充顶部空位(生成新方块)
int emptyCount = 0;
for (int row = 0; row < GameManager.ROWS; row++)
{
if (_grid[row, col] == PieceType.Empty)
{
SpawnNewPiece(row, col, emptyCount);
emptyCount++;
hasMoved = true;
}
}
}
if (hasMoved)
{
// 等待下落动画完成
await ToSignal(GetTree().CreateTimer(0.3), SceneTree.TimerSignalName.Timeout);
// 下落后可能产生新的匹配
_gameManager.CurrentState = GameState.Checking;
var newMatches = FindMatches();
if (newMatches.Count > 0)
{
// 连锁反应!
_gameManager.IncrementCombo();
ProcessMatches(newMatches);
}
else
{
// 没有新匹配,回到等待输入状态
_gameManager.ResetCombo();
_gameManager.CurrentState = GameState.Idle;
}
}
else
{
_gameManager.CurrentState = GameState.Idle;
}
}
/// <summary>
/// 将方块从一行移动到另一行(同一列内)
/// </summary>
private void MovePieceDown(int fromRow, int col, int toRow, int toCol)
{
// 移动数据
_grid[toRow, toCol] = _grid[fromRow, col];
_grid[fromRow, col] = PieceType.Empty;
// 移动节点引用
_pieces[toRow, toCol] = _pieces[fromRow, col];
_pieces[fromRow, col] = null;
// 更新方块的位置(带动画)
if (_pieces[toRow, toCol] != null)
{
Piece piece = _pieces[toRow, toCol];
piece.Row = toRow;
piece.Col = toCol;
// 下落动画
Vector2 targetPos = GridToPixel(toRow, toCol);
Tween tween = CreateTween();
tween.TweenProperty(piece, "position", targetPos, 0.2f)
.SetTrans(Tween.TransitionType.Quad)
.SetEase(Tween.EaseType.In);
}
}
/// <summary>
/// 在指定位置生成新的随机方块
/// </summary>
private void SpawnNewPiece(int row, int col, int emptyIndex)
{
// 随机生成方块类型
PieceType type = (PieceType)new Random().Next(GameManager.PIECE_TYPES);
_grid[row, col] = type;
// 创建方块节点
Piece piece = PieceScene.Instantiate<Piece>();
piece.Initialize(type, row, col);
piece.PieceClicked += OnPieceClicked;
// 初始位置在棋盘上方
Vector2 targetPos = GridToPixel(row, col);
piece.Position = new Vector2(targetPos.X,
-GameManager.PIECE_SIZE * (emptyIndex + 1));
AddChild(piece);
_pieces[row, col] = piece;
// 下落动画
Tween tween = CreateTween();
tween.TweenProperty(piece, "position", targetPos, 0.3f)
.SetTrans(Tween.TransitionType.Bounce)
.SetEase(Tween.EaseType.Out);
}GDScript
## 处理方块下落和填充
## 消除完成后调用此方法
func apply_gravity() -> void:
_game_manager.current_state = GameManager.GameState.FALLING
print("[GameBoard] 开始下落填充...")
var has_moved: bool = false
# 逐列处理
for col in range(GameManager.COLS):
# 从倒数第二行开始往上扫描(最底行不需要下落)
for row in range(GameManager.ROWS - 2, -1, -1):
if grid[row][col] == GameManager.PieceType.EMPTY:
# 找到空位,往上找第一个非空的方块
for above in range(row - 1, -1, -1):
if grid[above][col] != GameManager.PieceType.EMPTY:
# 把上面的方块移到这个空位
_move_piece_down(above, col, row, col)
has_moved = true
break
# 填充顶部空位(生成新方块)
var empty_count: int = 0
for row in range(GameManager.ROWS):
if grid[row][col] == GameManager.PieceType.EMPTY:
_spawn_new_piece(row, col, empty_count)
empty_count += 1
has_moved = true
if has_moved:
# 等待下落动画完成
await get_tree().create_timer(0.3).timeout
# 下落后可能产生新的匹配
_game_manager.current_state = GameManager.GameState.CHECKING
var new_matches := find_matches()
if new_matches.size() > 0:
# 连锁反应!
_game_manager.increment_combo()
_process_matches(new_matches)
else:
# 没有新匹配,回到等待输入状态
_game_manager.reset_combo()
_game_manager.current_state = GameManager.GameState.IDLE
else:
_game_manager.current_state = GameManager.GameState.IDLE
## 将方块从一行移动到另一行(同一列内)
func _move_piece_down(from_row: int, col: int, to_row: int, to_col: int) -> void:
# 移动数据
grid[to_row][to_col] = grid[from_row][col]
grid[from_row][col] = GameManager.PieceType.EMPTY
# 移动节点引用
pieces[to_row][to_col] = pieces[from_row][col]
pieces[from_row][col] = null
# 更新方块的位置(带动画)
if pieces[to_row][to_col] != null:
var piece: Piece = pieces[to_row][to_col]
piece.row = to_row
piece.col = to_col
# 下落动画
var target_pos: Vector2 = grid_to_pixel(to_row, to_col)
var tween := create_tween()
tween.tween_property(piece, "position", target_pos, 0.2) \
.set_trans(Tween.TransitionType.QUAD) \
.set_ease(Tween.EaseType.IN)
## 在指定位置生成新的随机方块
func _spawn_new_piece(row: int, col: int, empty_index: int) -> void:
# 随机生成方块类型
var rng := RandomNumberGenerator.new()
var piece_type: int = rng.randi_range(0, GameManager.PIECE_TYPES - 1)
grid[row][col] = piece_type
# 创建方块节点
var piece: Piece = piece_scene.instantiate()
piece.initialize(piece_type, row, col)
piece.piece_clicked.connect(_on_piece_clicked)
# 初始位置在棋盘上方
var target_pos: Vector2 = grid_to_pixel(row, col)
piece.position = Vector2(
target_pos.x,
-GameManager.PIECE_SIZE * (empty_index + 1)
)
add_child(piece)
pieces[row][col] = piece
# 下落动画
var tween := create_tween()
tween.tween_property(piece, "position", target_pos, 0.3) \
.set_trans(Tween.TransitionType.BOUNCE) \
.set_ease(Tween.EaseType.OUT)拖拽交换(进阶)
除了点击两个方块来交换,很多三消游戏还支持拖拽交换——按住一个方块,向相邻方向拖动。这种操作方式更直觉、更流畅。
核心思路是检测鼠标的拖拽方向:
| 拖拽方向 | 交换对象 |
|---|---|
| 向上 | 当前方块与上方方块交换 |
| 向下 | 当前方块与下方方块交换 |
| 向左 | 当前方块与左方方块交换 |
| 向右 | 当前方块与右方方块交换 |
C
/// <summary>拖拽开始位置</summary>
private Vector2 _dragStartPos;
/// <summary>拖拽的最小距离(像素)</summary>
private const float DRAG_THRESHOLD = 20f;
/// <summary>正在拖拽的方块</summary>
private Piece _draggingPiece;
public override void _Input(InputEvent inputEvent)
{
if (_gameManager.CurrentState != GameState.Idle)
return;
if (inputEvent is InputEventMouseButton mouseButton)
{
if (mouseButton.Pressed && mouseButton.ButtonIndex == MouseButton.Left)
{
// 记录拖拽起始位置
_dragStartPos = mouseButton.Position;
}
}
else if (inputEvent is InputEventMouseMotion mouseMotion)
{
if (Input.IsMouseButtonPressed(MouseButton.Left) && _draggingPiece == null)
{
// 检查是否超过拖拽阈值
float distance = _dragStartPos.DistanceTo(mouseMotion.Position);
if (distance > DRAG_THRESHOLD)
{
// 确定拖拽方向
Vector2 direction = (mouseMotion.Position - _dragStartPos).Normalized();
Piece target = GetDragTarget(_draggingPiece, direction);
if (target != null)
{
TrySwap(_draggingPiece, target);
_draggingPiece = null;
}
}
}
}
}
/// <summary>
/// 根据拖拽方向获取要交换的目标方块
/// </summary>
private Piece GetDragTarget(Piece source, Vector2 direction)
{
int targetRow = source.Row;
int targetCol = source.Col;
// 判断主方向(取绝对值较大的分量)
if (Mathf.Abs(direction.X) > Mathf.Abs(direction.Y))
{
targetCol += direction.X > 0 ? 1 : -1; // 水平方向
}
else
{
targetRow += direction.Y > 0 ? 1 : -1; // 垂直方向
}
// 检查目标位置是否在棋盘范围内
if (IsInBounds(targetRow, targetCol))
{
return GetPiece(targetRow, targetCol);
}
return null;
}GDScript
## 拖拽开始位置
var _drag_start_pos: Vector2 = Vector2.ZERO
## 拖拽的最小距离(像素)
const DRAG_THRESHOLD: float = 20.0
## 正在拖拽的方块
var _dragging_piece: Piece = null
func _input(input_event: InputEvent) -> void:
if _game_manager.current_state != GameManager.GameState.IDLE:
return
if input_event is InputEventMouseButton:
var mouse_button := input_event as InputEventMouseButton
if mouse_button.pressed and mouse_button.button_index == MOUSE_BUTTON_LEFT:
# 记录拖拽起始位置
_drag_start_pos = mouse_button.position
elif input_event is InputEventMouseMotion:
var mouse_motion := input_event as InputEventMouseMotion
if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and _dragging_piece == null:
# 检查是否超过拖拽阈值
var distance: float = _drag_start_pos.distance_to(mouse_motion.position)
if distance > DRAG_THRESHOLD:
# 确定拖拽方向
var direction: Vector2 = (mouse_motion.position - _drag_start_pos).normalized()
var target: Piece = _get_drag_target(_dragging_piece, direction)
if target != null:
_try_swap(_dragging_piece, target)
_dragging_piece = null
## 根据拖拽方向获取要交换的目标方块
func _get_drag_target(source: Piece, direction: Vector2) -> Piece:
var target_row: int = source.row
var target_col: int = source.col
# 判断主方向(取绝对值较大的分量)
if absf(direction.x) > absf(direction.y):
target_col += 1 if direction.x > 0 else -1 # 水平方向
else:
target_row += 1 if direction.y > 0 else -1 # 垂直方向
# 检查目标位置是否在棋盘范围内
if is_in_bounds(target_row, target_col):
return get_piece(target_row, target_col)
return null本章小结
| 完成项 | 说明 |
|---|---|
| 点击选中 | 第一次点击选中,第二次点击交换或切换 |
| 相邻检测 | 只允许交换上下左右相邻的方块 |
| 交换动画 | 使用 Tween 实现平滑的位置交换 |
| 无效回退 | 交换后没有匹配时自动回退,不消耗步数 |
| 下落填充 | 消除后上方方块下落,顶部生成新方块 |
| 拖拽交换 | 支持按住拖动方向来交换方块 |
现在玩家可以自如地操作方块了。下一章,我们将实现计分与连击系统,让每次消除都有"数字跳动"的满足感。
