7. 暂存与预览
2026/4/14大约 9 分钟
7. 俄罗斯方块——暂存与预览
7.1 暂存和预览是什么?
想象你在超市购物:你推着购物车,里面已经放了几样东西(这是暂存/Hold),你抬头看了一下货架上接下来的商品(这是预览/Next)。
在俄罗斯方块中:
- Hold(暂存):你可以把当前方块"存起来",以后需要的时候再拿出来。就像把不想要的商品先放回货架。
- Next(预览):屏幕旁边显示接下来会出现哪几个方块。就像提前知道接下来几轮会出什么。
这两个功能让玩家可以提前规划策略,而不仅仅是手忙脚乱地应对。
7.2 Hold(暂存)功能
使用规则
| 规则 | 说明 |
|---|---|
| 何时可用 | 任何时候都可以按Hold键 |
| 每回合限制 | 每放置一个方块后才能再次使用Hold |
| 空Hold区 | 把当前方块存入Hold区,生成下一个方块 |
| 有Hold方块 | 交换当前方块和Hold区的方块 |
| O形方块 | 可以Hold,但通常没必要 |
Hold界面设计
Hold区通常显示在游戏面板的左上方:
┌──────────────────┐
│ ┌────────┐ 分数 │
│ │ HOLD │ 0 │
│ │ ┌──┐ │ │
│ │ │■■│ │ 等级 │
│ │ │■■│ │ 1 │
│ │ └──┘ │ │
│ └────────┘ 消行 │
│ 0 │
│ ┌────────────────┐
│ │ │
│ │ 游戏面板 │
│ │ 10 x 20 │
│ │ │
│ └────────────────┘
│ ┌──────┐ │
│ │ NEXT │ │
│ │ ■ │ │
│ │ ■■ │ │
│ │ ■ │ │
│ └──────┘ │
└──────────────────┘C
using Godot;
/// <summary>
/// Hold显示组件——显示暂存区中的方块
/// </summary>
public partial class HoldDisplay : Control
{
// 子节点
private PanelContainer _background;
private Label _titleLabel;
private Control _pieceContainer;
// 状态
private PieceType? _heldPiece = null;
private bool _usedThisTurn = false;
// 预设的方块绘制尺寸
private const int PreviewCellSize = 24;
public override void _Ready()
{
_background = GetNode<PanelContainer>("Background");
_titleLabel = GetNode<Label>("Background/Title");
_pieceContainer = GetNode<Control>("Background/PieceContainer");
_titleLabel.Text = "HOLD";
ClearDisplay();
}
/// <summary>
/// 更新Hold显示
/// </summary>
public void UpdateDisplay(PieceType? pieceType, bool usedThisTurn)
{
_heldPiece = pieceType;
_usedThisTurn = usedThisTurn;
// 清空旧显示
foreach (var child in _pieceContainer.GetChildren())
{
child.QueueFree();
}
if (pieceType == null)
{
// Hold区为空
var emptyLabel = new Label();
emptyLabel.Text = "空";
emptyLabel.AddThemeFontSizeOverride("font_size", 14);
_pieceContainer.AddChild(emptyLabel);
return;
}
// 绘制方块预览
DrawPiecePreview(pieceType.Value);
}
/// <summary>
/// 绘制方块预览图
/// </summary>
private void DrawPiecePreview(PieceType type)
{
var shape = PieceData.GetShape(type, 0);
var color = PieceData.GetColor(type);
int rows = shape.GetLength(0);
int cols = shape.GetLength(1);
// 创建一个自定义绘制节点
var previewNode = new Control();
previewNode.CustomMinimumSize = new Vector2(
cols * PreviewCellSize,
rows * PreviewCellSize
);
// 居中偏移(让方块在容器中居中)
var offset = new Vector2(
-cols * PreviewCellSize / 2f + PreviewCellSize / 2f,
-rows * PreviewCellSize / 2f + PreviewCellSize / 2f
);
previewNode.Draw += (Node2D sender) =>
{
for (int row = 0; row < rows; row++)
{
for (int col = 0; col < cols; col++)
{
if (shape[row, col] != 0)
{
var rect = new Rect2(
offset.X + col * PreviewCellSize,
offset.Y + row * PreviewCellSize,
PreviewCellSize - 1,
PreviewCellSize - 1
);
sender.DrawRect(rect, color);
sender.DrawRect(rect, color.Lightened(0.2f), false, 1f);
}
}
}
};
// 如果本回合已经使用过Hold,显示为灰色
if (_usedThisTurn)
{
previewNode.Modulate = new Color(0.5f, 0.5f, 0.5f, 0.5f);
}
_pieceContainer.AddChild(previewNode);
}
/// <summary>
/// 清空Hold显示
/// </summary>
public void ClearDisplay()
{
UpdateDisplay(null, false);
}
}GDScript
extends Control
## Hold显示组件——显示暂存区中的方块
# 子节点
@onready var _background = $Background
@onready var _title_label = $Background/Title
@onready var _piece_container = $Background/PieceContainer
# 状态
var _held_piece: int = -1
var _used_this_turn: bool = false
# 预设的方块绘制尺寸
const PREVIEW_CELL_SIZE: int = 24
func _ready() -> void:
_title_label.text = "HOLD"
_clear_display()
## 更新Hold显示
func update_display(piece_type: int, used_this_turn: bool) -> void:
_held_piece = piece_type
_used_this_turn = used_this_turn
# 清空旧显示
for child in _piece_container.get_children():
child.queue_free()
if piece_type == -1:
# Hold区为空
var empty_label = Label.new()
empty_label.text = "空"
empty_label.add_theme_font_size_override("font_size", 14)
_piece_container.add_child(empty_label)
return
# 绘制方块预览
_draw_piece_preview(piece_type)
## 绘制方块预览图
func _draw_piece_preview(type: int) -> void:
var shape = PieceData.get_shape(type, 0)
var color = PieceData.get_color(type)
var rows = shape.size()
var cols = shape[0].size()
# 创建一个自定义绘制节点
var preview_node = Control.new()
preview_node.custom_minimum_size = Vector2(
cols * PREVIEW_CELL_SIZE,
rows * PREVIEW_CELL_SIZE
)
# 居中偏移
var offset = Vector2(
-cols * PREVIEW_CELL_SIZE / 2.0 + PREVIEW_CELL_SIZE / 2.0,
-rows * PREVIEW_CELL_SIZE / 2.0 + PREVIEW_CELL_SIZE / 2.0
)
preview_node.draw.connect(func() -> void:
for row in range(rows):
for col in range(cols):
if shape[row][col] != 0:
var rect = Rect2(
offset.x + col * PREVIEW_CELL_SIZE,
offset.y + row * PREVIEW_CELL_SIZE,
PREVIEW_CELL_SIZE - 1,
PREVIEW_CELL_SIZE - 1
)
preview_node.draw_rect(rect, color)
preview_node.draw_rect(rect, color.lightened(0.2), false, 1.0)
)
# 如果本回合已经使用过Hold,显示为灰色
if _used_this_turn:
preview_node.modulate = Color(0.5, 0.5, 0.5, 0.5)
_piece_container.add_child(preview_node)
## 清空Hold显示
func _clear_display() -> void:
update_display(-1, false)7.3 Next(预览)队列
Next队列显示接下来的几个方块。现代俄罗斯方块通常显示3个预览方块。
预览队列的工作方式
时间线:
当前方块 → Next1 → Next2 → Next3 → [袋子中...]
当当前方块被放置后:
Next1变成新的当前方块
Next2变成Next1
Next3变成Next2
袋子中取出一个变成Next3C
using Godot;
using System.Collections.Generic;
/// <summary>
/// Next预览队列显示组件
/// </summary>
public partial class NextDisplay : Control
{
[Export] public int PreviewCount { get; set; } = 3;
// 子节点容器
private VBoxContainer _container;
// 预览方块绘制尺寸(逐级缩小)
private static readonly int[] PreviewSizes = { 24, 20, 16 };
public override void _Ready()
{
_container = GetNode<VBoxContainer>("Background/PreviewContainer");
}
/// <summary>
/// 更新预览队列显示
/// </summary>
public void UpdateDisplay(PieceSpawner spawner)
{
// 清空旧显示
foreach (var child in _container.GetChildren())
{
child.QueueFree();
}
// 显示预览方块
for (int i = 0; i < PreviewCount; i++)
{
PieceType type = spawner.PeekPreview(i);
int cellSize = i < PreviewSizes.Length
? PreviewSizes[i]
: PreviewSizes[PreviewSizes.Length - 1];
var slot = CreatePreviewSlot(type, cellSize, i);
_container.AddChild(slot);
}
}
/// <summary>
/// 创建单个预览方块槽位
/// </summary>
private Control CreatePreviewSlot(PieceType type, int cellSize, int index)
{
var slot = new Control();
slot.CustomMinimumSize = new Vector2(80, 60);
var shape = PieceData.GetShape(type, 0);
var color = PieceData.GetColor(type);
int rows = shape.GetLength(0);
int cols = shape.GetLength(1);
// 透明度逐级降低(远处的方块更淡)
float alpha = 1.0f - index * 0.15f;
var offset = new Vector2(
40 - cols * cellSize / 2f + cellSize / 2f,
30 - rows * cellSize / 2f + cellSize / 2f
);
slot.Draw += (Node2D sender) =>
{
for (int row = 0; row < rows; row++)
{
for (int col = 0; col < cols; col++)
{
if (shape[row, col] != 0)
{
var rect = new Rect2(
offset.X + col * cellSize,
offset.Y + row * cellSize,
cellSize - 1,
cellSize - 1
);
var drawColor = new Color(
color.R, color.G, color.B, alpha);
sender.DrawRect(rect, drawColor);
}
}
}
};
return slot;
}
}GDScript
extends Control
## Next预览队列显示组件
@export var preview_count: int = 3
# 子节点容器
@onready var _container = $Background/PreviewContainer
# 预览方块绘制尺寸(逐级缩小)
const PREVIEW_SIZES = [24, 20, 16]
func _ready() -> void:
pass
## 更新预览队列显示
func update_display(spawner: PieceSpawner) -> void:
# 清空旧显示
for child in _container.get_children():
child.queue_free()
# 显示预览方块
for i in range(preview_count):
var type: int = spawner.peek_preview(i)
var cell_size: int
if i < PREVIEW_SIZES.size():
cell_size = PREVIEW_SIZES[i]
else:
cell_size = PREVIEW_SIZES[PREVIEW_SIZES.size() - 1]
var slot = _create_preview_slot(type, cell_size, i)
_container.add_child(slot)
## 创建单个预览方块槽位
func _create_preview_slot(type: int, cell_size: int, index: int) -> Control:
var slot = Control.new()
slot.custom_minimum_size = Vector2(80, 60)
var shape = PieceData.get_shape(type, 0)
var color = PieceData.get_color(type)
var rows = shape.size()
var cols = shape[0].size()
# 透明度逐级降低(远处的方块更淡)
var alpha = 1.0 - index * 0.15
var offset = Vector2(
40 - cols * cell_size / 2.0 + cell_size / 2.0,
30 - rows * cell_size / 2.0 + cell_size / 2.0
)
slot.draw.connect(func() -> void:
for row in range(rows):
for col in range(cols):
if shape[row][col] != 0:
var rect = Rect2(
offset.x + col * cell_size,
offset.y + row * cell_size,
cell_size - 1,
cell_size - 1
)
var draw_color = Color(color.r, color.g, color.b, alpha)
slot.draw_rect(rect, draw_color)
)
return slot7.4 完整的方块流转逻辑
现在我们把Hold、Next和方块生成器串联起来,形成完整的方块流转逻辑:
┌──────────────┐
│ 7-Bag 袋子 │
│ IOTSZJL 打乱 │
└──────┬───────┘
│
▼
┌────────────────────────┐
│ Next预览队列 │
│ [方块A] [方块B] [方块C]│
└───────┬────────────────┘
│
▼
┌───────────────┐
│ 当前活动方块 │ ← 玩家控制这个
└───┬───────┬───┘
│ │
按Hold键│ │方块锁定
▼ ▼
┌──────────┐ ┌──────────────┐
│ Hold交换 │ │ 消行 + 计分 │
│ 或存入 │ │ 生成下一个 │
└──────────┘ └──────────────┘C
/// <summary>
/// GameBoard中的方块流转管理
/// </summary>
public partial class GameBoard : Node2D
{
// 引用
[Export] public PieceSpawner Spawner { get; set; }
[Export] public HoldManager HoldManager { get; set; }
[Export] public NextDisplay NextDisplay { get; set; }
[Export] public HoldDisplay HoldDisplay { get; set; }
// 当前方块
private Piece _activePiece;
private PieceType _currentType;
/// <summary>
/// 生成下一个方块
/// </summary>
public void SpawnNextPiece()
{
// 从生成器获取下一个方块类型
_currentType = Spawner.GetNextPiece();
// 创建方块实例
_activePiece = new Piece();
_activePiece.Initialize(
_currentType,
GameConstants.SpawnColumn,
GameConstants.SpawnRow
);
AddChild(_activePiece);
// 检查是否游戏结束
if (!GridNode.IsValidPosition(
_activePiece.CurrentShape,
_activePiece.GridX,
_activePiece.GridY))
{
GameManager.Instance.GameOver();
return;
}
// 更新预览显示
NextDisplay.UpdateDisplay(Spawner);
// 重置Hold使用状态
HoldManager.ResetHoldState();
HoldDisplay.UpdateDisplay(
HoldManager.GetHeldPiece(),
false
);
}
/// <summary>
/// 执行Hold操作
/// </summary>
public void PerformHold()
{
if (_activePiece == null) return;
// 尝试Hold
PieceType? result = HoldManager.Hold(_currentType);
if (result == _currentType)
{
// Hold失败(本回合已使用过)
return;
}
// 移除当前方块
_activePiece.QueueFree();
_activePiece = null;
if (result == null)
{
// Hold区为空,直接生成下一个
SpawnNextPiece();
}
else
{
// Hold区有方块,交换
_currentType = result.Value;
_activePiece = new Piece();
_activePiece.Initialize(
_currentType,
GameConstants.SpawnColumn,
GameConstants.SpawnRow
);
AddChild(_activePiece);
}
// 更新Hold显示
HoldDisplay.UpdateDisplay(
HoldManager.GetHeldPiece(),
HoldManager.UsedThisTurn
);
}
/// <summary>
/// 方块锁定后的处理
/// </summary>
public void OnPieceLocked()
{
// 将方块写入网格
int colorIndex = (int)_currentType + 1;
GridNode.LockPiece(
_activePiece.CurrentShape,
_activePiece.GridX,
_activePiece.GridY,
colorIndex
);
// 移除方块节点
_activePiece.QueueFree();
_activePiece = null;
// 检测满行
var fullLines = GridNode.FindFullLines();
if (fullLines.Count > 0)
{
// 有满行,播放动画
_clearAnimator.StartAnimation(fullLines);
}
else
{
// 没有满行,直接生成下一个
SpawnNextPiece();
}
}
}GDScript
## GameBoard中的方块流转管理
# 引用
@export var spawner: PieceSpawner
@export var hold_manager: HoldManager
@export var next_display: NextDisplay
@export var hold_display: HoldDisplay
# 当前方块
var _active_piece: Piece
var _current_type: int
## 生成下一个方块
func _spawn_next_piece() -> void:
# 从生成器获取下一个方块类型
_current_type = spawner.get_next_piece()
# 创建方块实例
_active_piece = Piece.new()
_active_piece.initialize(
_current_type,
GameConstants.SPAWN_COLUMN,
GameConstants.SPAWN_ROW
)
add_child(_active_piece)
# 检查是否游戏结束
if not grid_node.is_valid_position(
_active_piece.current_shape,
_active_piece.grid_x,
_active_piece.grid_y
):
GameManager.game_over()
return
# 更新预览显示
next_display.update_display(spawner)
# 重置Hold使用状态
hold_manager.reset_hold_state()
hold_display.update_display(
hold_manager.get_held_piece(),
false
)
## 执行Hold操作
func _perform_hold() -> void:
if _active_piece == null:
return
# 尝试Hold
var result = hold_manager.hold(_current_type)
if result == _current_type:
# Hold失败(本回合已使用过)
return
# 移除当前方块
_active_piece.queue_free()
_active_piece = null
if result == -1:
# Hold区为空,直接生成下一个
_spawn_next_piece()
else:
# Hold区有方块,交换
_current_type = result
_active_piece = Piece.new()
_active_piece.initialize(
_current_type,
GameConstants.SPAWN_COLUMN,
GameConstants.SPAWN_ROW
)
add_child(_active_piece)
# 更新Hold显示
hold_display.update_display(
hold_manager.get_held_piece(),
hold_manager.used_this_turn
)
## 方块锁定后的处理
func _on_piece_locked() -> void:
# 将方块写入网格
var color_index = _current_type + 1
grid_node.lock_piece(
_active_piece.current_shape,
_active_piece.grid_x,
_active_piece.grid_y,
color_index
)
# 移除方块节点
_active_piece.queue_free()
_active_piece = null
# 检测满行
var full_lines = grid_node.find_full_lines()
if full_lines.size() > 0:
# 有满行,播放动画
_clear_animator.start_animation(full_lines)
else:
# 没有满行,直接生成下一个
_spawn_next_piece()7.5 7-Bag系统的完整实现回顾
7-Bag系统确保公平性的关键在于洗牌算法。我们使用Fisher-Yates洗牌算法,它能保证每种排列组合出现的概率完全相等。
7-Bag系统流程:
1. 准备一个袋子,放入 [I, O, T, S, Z, J, L]
2. 随机打乱顺序,比如变成 [S, L, T, I, O, Z, J]
3. 按顺序取出:先给玩家S,再给L,再给T...
4. 袋子空了,重新装满并打乱
结果:每7个方块中,7种各出现恰好1次| 特性 | 说明 |
|---|---|
| 公平性 | 每种方块出现频率相同 |
| 可预测性 | 玩家可以通过预览规划策略 |
| 无连续重复 | 同种方块至少间隔7个 |
| 随机性 | 每一轮的顺序都不同 |
7.6 本章小结
| 功能 | 说明 |
|---|---|
| Hold暂存 | 把当前方块存起来,每回合限用一次 |
| Hold显示 | 左上方显示暂存区中的方块 |
| Next预览 | 右下方显示接下来3个方块 |
| 预览渐变 | 越远的方块越小越淡 |
| 7-Bag系统 | 公平的方块生成系统 |
| 方块流转 | Hold、Next、生成之间的完整流转逻辑 |
关键设计点:
- Hold每回合只能用一次,防止玩家无限交换
- 预览方块逐级缩小,突出"下一个"的重要性
- 7-Bag系统保证了公平性,消除了纯随机的运气因素
下一章我们将搭建游戏UI界面。
