6. 关卡与速度递增
2026/4/14大约 9 分钟
6. 俄罗斯方块——关卡与速度递增
6.1 为什么需要关卡和速度递增?
想象你一直在玩一个速度永远不变的游戏——几分钟之后你就会觉得无聊,因为挑战消失了。关卡和速度递增是让游戏持续有趣的关键:随着你消的行越来越多,方块下落的速度也越来越快,你需要越来越快的反应速度。
这就像跑步机:一开始速度慢,你跑得很轻松;但随着时间推移,速度不断加快,你不得不加快脚步跟上。
6.2 等级系统
等级如何提升?
我们采用最常见的规则:每消除10行升一级。
| 等级 | 累计消行数 | 下落间隔 |
|---|---|---|
| 1 | 0-9 | 1000ms |
| 2 | 10-19 | 925ms |
| 3 | 20-29 | 850ms |
| 4 | 30-39 | 775ms |
| 5 | 40-49 | 700ms |
| ... | ... | ... |
| 13 | 120-129 | 100ms |
| 14 | 130-139 | 83ms |
| 15+ | 140+ | 50ms(最快) |
下落速度公式
下落间隔 = max(50, 1000 - (等级 - 1) * 75) 毫秒这个公式的含义是:
- 等级1时,每1000毫秒(1秒)下落一格
- 每升一级,间隔减少75毫秒
- 最快不超过50毫秒(不然人眼都看不清了)
C
/// <summary>
/// 等级系统
/// </summary>
public partial class LevelSystem : Node
{
// ===== 速度参数 =====
private const float BaseDropInterval = 1000f; // 等级1的下落间隔(ms)
private const float SpeedIncreasePerLevel = 75f; // 每级减少的间隔(ms)
private const float MinDropInterval = 50f; // 最快间隔(ms)
private const int LinesPerLevel = 10; // 每级需要的消行数
// ===== 状态 =====
private int _level = 1;
private int _totalLines = 0;
// ===== 信号 =====
[Signal] public delegate void LevelUpEventHandler(int newLevel);
[Signal] public delegate void SpeedChangedEventHandler(float newInterval);
/// <summary>
/// 获取当前等级
/// </summary>
public int Level => _level;
/// <summary>
/// 获取当前下落间隔(秒)
/// </summary>
public float DropInterval
{
get
{
float interval = BaseDropInterval - (_level - 1) * SpeedIncreasePerLevel;
return Mathf.Max(interval, MinDropInterval) / 1000f;
}
}
/// <summary>
/// 获取到下一级还需要消多少行
/// </summary>
public int LinesToNextLevel => LinesPerLevel - (_totalLines % LinesPerLevel);
/// <summary>
/// 添加消行数,检查是否升级
/// </summary>
public void AddLines(int lines)
{
if (lines <= 0) return;
int oldLevel = _level;
_totalLines += lines;
// 计算新等级
_level = (_totalLines / LinesPerLevel) + 1;
if (_level > oldLevel)
{
GD.Print($"升级!{_level - 1} → {_level}");
EmitSignal(SignalName.LevelUp, _level);
EmitSignal(SignalName.SpeedChanged, DropInterval);
}
}
/// <summary>
/// 重置等级系统
/// </summary>
public void Reset()
{
_level = 1;
_totalLines = 0;
}
}GDScript
extends Node
## 等级系统
# ===== 速度参数 =====
const BASE_DROP_INTERVAL: float = 1000.0 # 等级1的下落间隔(ms)
const SPEED_INCREASE_PER_LEVEL: float = 75.0 # 每级减少的间隔(ms)
const MIN_DROP_INTERVAL: float = 50.0 # 最快间隔(ms)
const LINES_PER_LEVEL: int = 10 # 每级需要的消行数
# ===== 状态 =====
var _level: int = 1
var _total_lines: int = 0
# ===== 信号 =====
signal level_up(new_level: int)
signal speed_changed(new_interval: float)
## 获取当前等级
func get_level() -> int:
return _level
## 获取当前下落间隔(秒)
func get_drop_interval() -> float:
var interval: float = BASE_DROP_INTERVAL - (_level - 1) * SPEED_INCREASE_PER_LEVEL
return maxf(interval, MIN_DROP_INTERVAL) / 1000.0
## 获取到下一级还需要消多少行
func get_lines_to_next_level() -> int:
return LINES_PER_LEVEL - (_total_lines % LINES_PER_LEVEL)
## 添加消行数,检查是否升级
func add_lines(lines: int) -> void:
if lines <= 0:
return
var old_level = _level
_total_lines += lines
# 计算新等级
_level = (_total_lines / LINES_PER_LEVEL) + 1
if _level > old_level:
print("升级!", _level - 1, " → ", _level)
level_up.emit(_level)
speed_changed.emit(get_drop_interval())
## 重置等级系统
func reset() -> void:
_level = 1
_total_lines = 06.3 自动下落计时器
方块需要按照当前等级的速度自动下落。我们用一个计时器来控制下落的节奏。
想象一个闹钟,每隔一段时间(比如1秒)响一次。每次响的时候,方块就自动往下移一格。
C
using Godot;
/// <summary>
/// 方块下落控制器
/// 管理自动下落、软降和硬降
/// </summary>
public partial class DropController : Node
{
// 引用
private GameBoard _board;
private Piece _activePiece;
private LevelSystem _levelSystem;
// 下落计时器
private float _dropTimer = 0f;
private bool _isSoftDropping = false;
// 锁定延迟
private float _lockTimer = 0f;
private bool _isOnGround = false;
private int _lockMoveCount = 0;
// 信号
[Signal] public delegate void PieceDroppedEventHandler(int cellsDropped);
[Signal] public delegate void PieceLockedEventHandler();
public override void _Process(double delta)
{
if (_activePiece == null) return;
// 更新下落计时器
_dropTimer += (float)delta;
float interval = _isSoftDropping
? Mathf.Min(_levelSystem.DropInterval, 0.05f) // 软降最快50ms
: _levelSystem.DropInterval;
if (_dropTimer >= interval)
{
_dropTimer = 0f;
TryDropOneLine();
}
// 处理锁定延迟
if (_isOnGround)
{
_lockTimer += (float)delta;
if (_lockTimer >= GameConstants.LockDelay
|| _lockMoveCount >= GameConstants.MaxLockResets)
{
LockPiece();
}
}
}
/// <summary>
/// 尝试下落一格
/// </summary>
private void TryDropOneLine()
{
if (!_board.GridNode.IsValidPosition(
_activePiece.CurrentShape,
_activePiece.GridX,
_activePiece.GridY + 1))
{
// 下方被挡住了,开始锁定延迟
if (!_isOnGround)
{
_isOnGround = true;
_lockTimer = 0f;
_lockMoveCount = 0;
}
return;
}
// 下方是空的,下落一格
_activePiece.MoveTo(_activePiece.GridX, _activePiece.GridY + 1);
// 软降加分
if (_isSoftDropping)
{
GameManager.Instance.AddScore(ScoringSystem.SoftDropPerCell);
}
// 如果之前在地面但现在不在了(被移到了空中),重置锁定
_isOnGround = false;
_lockTimer = 0f;
}
/// <summary>
/// 硬降——直接落到底
/// </summary>
public void HardDrop()
{
if (_activePiece == null) return;
int cellsDropped = 0;
// 一直往下直到碰到底
while (_board.GridNode.IsValidPosition(
_activePiece.CurrentShape,
_activePiece.GridX,
_activePiece.GridY + 1))
{
_activePiece.MoveTo(_activePiece.GridX, _activePiece.GridY + 1);
cellsDropped++;
}
// 硬降加分
GameManager.Instance.AddScore(
ScoringSystem.CalculateHardDropScore(cellsDropped));
EmitSignal(SignalName.PieceDropped, cellsDropped);
LockPiece();
}
/// <summary>
/// 锁定当前方块
/// </summary>
private void LockPiece()
{
EmitSignal(SignalName.PieceLocked);
_isOnGround = false;
_lockTimer = 0f;
_lockMoveCount = 0;
}
/// <summary>
/// 重置锁定计时器(当玩家移动或旋转方块时调用)
/// </summary>
public void ResetLockTimer()
{
if (_isOnGround)
{
_lockTimer = 0f;
_lockMoveCount++;
}
}
/// <summary>
/// 设置软降状态
/// </summary>
public void SetSoftDrop(bool active)
{
_isSoftDropping = active;
}
}GDScript
extends Node
## 方块下落控制器
## 管理自动下落、软降和硬降
# 引用
var _board: GameBoard
var _active_piece: Piece
var _level_system: LevelSystem
# 下落计时器
var _drop_timer: float = 0.0
var _is_soft_dropping: bool = false
# 锁定延迟
var _lock_timer: float = 0.0
var _is_on_ground: bool = false
var _lock_move_count: int = 0
# 信号
signal piece_dropped(cells_dropped: int)
signal piece_locked()
func _process(delta: float) -> void:
if _active_piece == null:
return
# 更新下落计时器
_drop_timer += delta
var interval: float
if _is_soft_dropping:
interval = minf(_level_system.get_drop_interval(), 0.05)
else:
interval = _level_system.get_drop_interval()
if _drop_timer >= interval:
_drop_timer = 0.0
_try_drop_one_line()
# 处理锁定延迟
if _is_on_ground:
_lock_timer += delta
if _lock_timer >= GameConstants.LOCK_DELAY \
or _lock_move_count >= GameConstants.MAX_LOCK_RESETS:
_lock_piece()
## 尝试下落一格
func _try_drop_one_line() -> void:
if not _board.grid_node.is_valid_position(
_active_piece.current_shape,
_active_piece.grid_x,
_active_piece.grid_y + 1
):
# 下方被挡住了,开始锁定延迟
if not _is_on_ground:
_is_on_ground = true
_lock_timer = 0.0
_lock_move_count = 0
return
# 下方是空的,下落一格
_active_piece.move_to(_active_piece.grid_x, _active_piece.grid_y + 1)
# 软降加分
if _is_soft_dropping:
GameManager.add_score(ScoringSystem.SOFT_DROP_PER_CELL)
# 如果之前在地面但现在不在了,重置锁定
_is_on_ground = false
_lock_timer = 0.0
## 硬降——直接落到底
func hard_drop() -> void:
if _active_piece == null:
return
var cells_dropped: int = 0
# 一直往下直到碰到底
while _board.grid_node.is_valid_position(
_active_piece.current_shape,
_active_piece.grid_x,
_active_piece.grid_y + 1
):
_active_piece.move_to(_active_piece.grid_x, _active_piece.grid_y + 1)
cells_dropped += 1
# 硬降加分
GameManager.add_score(
ScoringSystem.calculate_hard_drop_score(cells_dropped))
piece_dropped.emit(cells_dropped)
_lock_piece()
## 锁定当前方块
func _lock_piece() -> void:
piece_locked.emit()
_is_on_ground = false
_lock_timer = 0.0
_lock_move_count = 0
## 重置锁定计时器(当玩家移动或旋转方块时调用)
func reset_lock_timer() -> void:
if _is_on_ground:
_lock_timer = 0.0
_lock_move_count += 1
## 设置软降状态
func set_soft_drop(active: bool) -> void:
_is_soft_dropping = active6.4 锁定延迟(Lock Delay)
锁定延迟是现代俄罗斯方块中一个非常重要的机制。当方块落到底部时,它不会立刻锁定,而是给玩家一个短暂的"宽限期"(通常是500毫秒),让玩家还能移动或旋转方块。
想象你开车到了路口红灯前——你还有一小段距离可以调整位置,不是一到停止线就立刻被"锁死"。
锁定延迟的规则:
- 方块触底后开始计时(500ms)
- 在计时期间,玩家移动或旋转方块,计时器重置
- 最多重置15次(防止无限拖延)
- 计时结束或达到重置上限,方块锁定
6.5 Ghost Piece(投影方块)
Ghost Piece 是一个半透明的"影子",显示当前方块最终会落到哪个位置。它帮助玩家判断方块应该放在哪里,是一个非常实用的辅助功能。
C
/// <summary>
/// Ghost Piece——显示方块的最终落点投影
/// </summary>
public partial class GhostPiece : Node2D
{
private Piece _activePiece;
private Grid _grid;
private int[,] _shape;
private int _ghostY;
/// <summary>
/// 更新Ghost位置(每帧调用)
/// </summary>
public void UpdateGhost(Piece piece, Grid grid)
{
_activePiece = piece;
_grid = grid;
if (piece == null)
{
Visible = false;
return;
}
Visible = true;
_shape = piece.CurrentShape;
// 从当前位置往下找,找到第一个不能放的位置
_ghostY = piece.GridY;
while (grid.IsValidPosition(_shape, piece.GridX, _ghostY + 1))
{
_ghostY++;
}
// 更新Ghost的视觉位置
Position = new Vector2(
piece.GridX * GameConstants.CellSize,
_ghostY * GameConstants.CellSize
);
QueueRedraw();
}
/// <summary>
/// 绘制Ghost(半透明轮廓)
/// </summary>
public override void _Draw()
{
if (_shape == null) return;
int rows = _shape.GetLength(0);
int cols = _shape.GetLength(1);
for (int row = 0; row < rows; row++)
{
for (int col = 0; col < cols; col++)
{
if (_shape[row, col] != 0)
{
var rect = new Rect2(
col * GameConstants.CellSize,
row * GameConstants.CellSize,
GameConstants.CellSize,
GameConstants.CellSize
);
// 半透明填充
DrawRect(rect, new Color(1, 1, 1, 0.15f));
// 虚线边框
DrawRect(rect, new Color(1, 1, 1, 0.4f), false, 2f);
}
}
}
}
}GDScript
extends Node2D
## Ghost Piece——显示方块的最终落点投影
var _active_piece: Piece
var _grid: Grid
var _shape: Array = []
var _ghost_y: int = 0
## 更新Ghost位置(每帧调用)
func update_ghost(piece: Piece, grid: Grid) -> void:
_active_piece = piece
_grid = grid
if piece == null:
visible = false
return
visible = true
_shape = piece.current_shape
# 从当前位置往下找,找到第一个不能放的位置
_ghost_y = piece.grid_y
while grid.is_valid_position(_shape, piece.grid_x, _ghost_y + 1):
_ghost_y += 1
# 更新Ghost的视觉位置
position = Vector2(
piece.grid_x * GameConstants.CELL_SIZE,
_ghost_y * GameConstants.CELL_SIZE
)
queue_redraw()
## 绘制Ghost(半透明轮廓)
func _draw() -> void:
if _shape.is_empty():
return
for row in range(_shape.size()):
for col in range(_shape[row].size()):
if _shape[row][col] != 0:
var rect = Rect2(
col * GameConstants.CELL_SIZE,
row * GameConstants.CELL_SIZE,
GameConstants.CELL_SIZE,
GameConstants.CELL_SIZE
)
# 半透明填充
draw_rect(rect, Color(1, 1, 1, 0.15))
# 虚线边框
draw_rect(rect, Color(1, 1, 1, 0.4), false, 2.0)6.6 升级特效
当玩家升级时,应该给一个明显的视觉反馈,让玩家感受到成就感。
C
/// <summary>
/// 升级特效
/// </summary>
public partial class LevelUpEffect : Control
{
private Label _levelLabel;
private AnimationPlayer _animPlayer;
public override void _Ready()
{
_levelLabel = GetNode<Label>("LevelLabel");
_animPlayer = GetNode<AnimationPlayer>("AnimationPlayer");
Visible = false;
}
/// <summary>
/// 显示升级特效
/// </summary>
public void ShowLevelUp(int newLevel)
{
_levelLabel.Text = $"LEVEL {newLevel}";
Visible = true;
// 播放放大-缩小-消失的动画
_animPlayer.Play("level_up");
// 动画结束后自动隐藏
_animPlayer.AnimationFinished += (animName) =>
{
if (animName == "level_up")
Visible = false;
};
}
}GDScript
extends Control
## 升级特效
@onready var _level_label = $LevelLabel
@onready var _anim_player = $AnimationPlayer
func _ready() -> void:
visible = false
## 显示升级特效
func show_level_up(new_level: int) -> void:
_level_label.text = "LEVEL %d" % new_level
visible = true
# 播放放大-缩小-消失的动画
_anim_player.play("level_up")
# 动画结束后自动隐藏
await _anim_player.animation_finished
visible = false6.7 本章小结
| 概念 | 说明 |
|---|---|
| 等级系统 | 每消10行升一级,等级影响分数倍率和下落速度 |
| 速度曲线 | 从1000ms递减到50ms,使用线性公式 |
| 自动下落 | 计时器控制,每隔一定时间方块自动下落一格 |
| 软降 | 按住下键加速下落,每格加1分 |
| 硬降 | 空格直接落到底,每格加2分 |
| 锁定延迟 | 触底后500ms宽限期,最多重置15次 |
| Ghost Piece | 半透明投影,显示方块最终落点 |
| 升级特效 | 升级时的视觉反馈 |
关键设计点:
- 速度递增要让玩家感受到压力逐渐增大,但不能突然变得不可能
- 锁定延迟给了新手更多的反应时间,是"易学难精"设计的好例子
- Ghost Piece是辅助功能,应该可以关闭(高手可能觉得太简单)
下一章我们将实现Hold暂存功能和Next预览队列。
