5. 消行与计分
2026/4/14大约 10 分钟
5. 俄罗斯方块——消行与计分
5.1 消行——俄罗斯方块的心跳
想象你搭积木:当你把一整层都搭满了,这一层就会"消失",上面的积木整整齐齐地往下落一层。消行就是俄罗斯方块中最让人满足的时刻——满行被消除,屏幕闪烁,分数飞涨。
消行系统需要处理三件事:
- 检测:找到所有被填满的行
- 消除:移除满行,让上面的行落下来
- 计分:根据消了多少行来计算得分
5.2 满行检测
检测逻辑很简单:从下往上扫描每一行,看看这一行的10个格子是不是全都有方块。
C
/// <summary>
/// 检测所有满行
/// 返回满行的行号列表(从下到上排列)
/// </summary>
public List<int> FindFullLines(int[,] cells, int columns, int rows)
{
var fullLines = new List<int>();
// 从最底部往上扫描
for (int y = rows - 1; y >= 0; y--)
{
bool isFull = true;
for (int x = 0; x < columns; x++)
{
if (cells[x, y] == 0)
{
isFull = false;
break; // 发现一个空格就够了,不用继续检查
}
}
if (isFull)
{
fullLines.Add(y);
}
}
return fullLines;
}GDScript
## 检测所有满行
## 返回满行的行号列表(从下到上排列)
func find_full_lines(cells: Array, columns: int, rows: int) -> Array:
var full_lines = []
# 从最底部往上扫描
for y in range(rows - 1, -1, -1):
var is_full = true
for x in range(columns):
if cells[x][y] == 0:
is_full = false
break # 发现一个空格就够了,不用继续检查
if is_full:
full_lines.append(y)
return full_lines5.3 消行与行下移
当检测到满行后,需要两步操作:
- 把满行消除
- 把满行上方的所有行往下移,填补空缺
想象一摞书,你把中间一本书抽走,上面的书就会自然地往下落一格。
C
/// <summary>
/// 消除指定的行并让上方行下移
/// </summary>
/// <param name="fullLines">要消除的行号列表</param>
/// <returns>实际消除的行数</returns>
public int RemoveLines(List<int> fullLines)
{
if (fullLines.Count == 0) return 0;
// 从下往上消除(这样行的索引不会乱)
fullLines.Sort((a, b) => b.CompareTo(a));
foreach (int lineY in fullLines)
{
// 从消除行开始,往上每一行都往下移一格
for (int y = lineY; y > 0; y--)
{
for (int x = 0; x < GameConstants.GridColumns; x++)
{
_cells[x, y] = _cells[x, y - 1];
}
}
// 最顶行(第0行)清空
for (int x = 0; x < GameConstants.GridColumns; x++)
{
_cells[x, 0] = 0;
}
}
QueueRedraw();
return fullLines.Count;
}GDScript
## 消除指定的行并让上方行下移
## 参数 full_lines: 要消除的行号列表
## 返回实际消除的行数
func remove_lines(full_lines: Array) -> int:
if full_lines.size() == 0:
return 0
# 从下往上消除(这样行的索引不会乱)
full_lines.sort()
full_lines.reverse()
for line_y in full_lines:
# 从消除行开始,往上每一行都往下移一格
var y = line_y
while y > 0:
for x in range(GameConstants.GRID_COLUMNS):
_cells[x][y] = _cells[x][y - 1]
y -= 1
# 最顶行(第0行)清空
for x in range(GameConstants.GRID_COLUMNS):
_cells[x][0] = 0
queue_redraw()
return full_lines.size()消行的视觉化过程
下面用一个具体例子说明消行的过程:
初始状态(第15行和第17行是满行):
行15: ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ← 满行!
行16: . . ■ . . . . ■ . .
行17: ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ← 满行!
行18: . . . ■ ■ . . . . .
行19: ■ . ■ ■ ■ ■ . . ■ .
第一步:消除第17行,上方行下移
行17变成了原来第16行的内容
第二步:消除第15行(注意它现在是原来的第14行之后的位置)
上方所有行再次下移
最终状态:
行15: . . ■ . . . . ■ . . ← 原来行16的内容
行16: . . . ■ ■ . . . . . ← 原来行18的内容
行17: ■ . ■ ■ ■ ■ . . ■ . ← 原来行19的内容
行18: . . . . . . . . . . ← 空行
行19: . . . . . . . . . . ← 空行5.4 消行动画
消行不应该瞬间消失,那样体验太"干"了。我们要加一个闪烁动画——满行先快速闪烁几次,然后消失。这样玩家能清楚地看到哪些行被消除了。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 消行动画控制器
/// </summary>
public partial class LineClearAnimator : Node
{
[Export] public Grid GridNode { get; set; }
// 动画参数
private const float FlashInterval = 0.1f; // 每次闪烁间隔(秒)
private const int FlashCount = 3; // 闪烁次数
// 动画状态
private bool _isAnimating = false;
private List<int> _clearingLines = new List<int>();
private int _currentFlash = 0;
private bool _flashVisible = true;
// 动画完成信号
[Signal] public delegate void AnimationFinishedEventHandler(int linesCleared);
/// <summary>
/// 开始消行动画
/// </summary>
public void StartAnimation(List<int> lines)
{
if (lines.Count == 0) return;
_isAnimating = true;
_clearingLines = new List<int>(lines);
_currentFlash = 0;
_flashVisible = true;
// 开始闪烁
_StartFlashTimer();
}
private void _StartFlashTimer()
{
var timer = GetTree().CreateTimer(FlashInterval);
timer.Timeout += OnFlashTimerTimeout;
}
private void OnFlashTimerTimeout()
{
_flashVisible = !_flashVisible;
_currentFlash++;
if (_currentFlash >= FlashCount * 2)
{
// 闪烁结束,执行实际消除
int count = GridNode.RemoveLines(_clearingLines);
_isAnimating = false;
EmitSignal(SignalName.AnimationFinished, count);
}
else
{
// 继续闪烁
GridNode.SetLineVisibility(_clearingLines, _flashVisible);
_StartFlashTimer();
}
}
/// <summary>
/// 是否正在播放动画
/// </summary>
public bool IsAnimating => _isAnimating;
}GDScript
extends Node
## 消行动画控制器
@export var grid_node: Grid
# 动画参数
const FLASH_INTERVAL: float = 0.1 # 每次闪烁间隔(秒)
const FLASH_COUNT: int = 3 # 闪烁次数
# 动画状态
var _is_animating: bool = false
var _clearing_lines: Array = []
var _current_flash: int = 0
var _flash_visible: bool = true
# 动画完成信号
signal animation_finished(lines_cleared: int)
## 开始消行动画
func start_animation(lines: Array) -> void:
if lines.size() == 0:
return
_is_animating = true
_clearing_lines = lines.duplicate()
_current_flash = 0
_flash_visible = true
# 开始闪烁
_start_flash_timer()
func _start_flash_timer() -> void:
var timer = get_tree().create_timer(FLASH_INTERVAL)
timer.timeout.connect(_on_flash_timer_timeout)
func _on_flash_timer_timeout() -> void:
_flash_visible = not _flash_visible
_current_flash += 1
if _current_flash >= FLASH_COUNT * 2:
# 闪烁结束,执行实际消除
var count = grid_node.remove_lines(_clearing_lines)
_is_animating = false
animation_finished.emit(count)
else:
# 继续闪烁
grid_node.set_line_visibility(_clearing_lines, _flash_visible)
_start_flash_timer()
## 是否正在播放动画
func is_animating() -> bool:
return _is_animating5.5 计分系统
计分系统是激励玩家不断挑战自我的核心机制。消的行越多,得分越高;等级越高,基础分越高。
标准计分表
| 动作 | 基础分 | 等级倍率 | 示例(等级5) |
|---|---|---|---|
| Single(消1行) | 100 | x等级 | 500分 |
| Double(消2行) | 300 | x等级 | 1500分 |
| Triple(消3行) | 500 | x等级 | 2500分 |
| Tetris(消4行) | 800 | x等级 | 4000分 |
| T-Spin Mini 0行 | 100 | x等级 | 500分 |
| T-Spin Mini Single | 200 | x等级 | 1000分 |
| T-Spin Single | 800 | x等级 | 4000分 |
| T-Spin Double | 1200 | x等级 | 6000分 |
| T-Spin Triple | 1600 | x等级 | 8000分 |
| 软降(每格) | 1 | 不变 | 1分/格 |
| 硬降(每格) | 2 | 不变 | 2分/格 |
Combo系统
Combo(连击) 是指连续消行的奖励。每次方块锁定后都消行,combo计数就加1;如果方块锁定后没有消行,combo归零。
| Combo次数 | 额外奖励 |
|---|---|
| 1次 | +50 x 等级 |
| 2次 | +100 x 等级 |
| 3次 | +150 x 等级 |
| n次 | +50 x n x 等级 |
C
/// <summary>
/// 完整计分系统
/// </summary>
public static class ScoringSystem
{
// ===== 基础消行分 =====
private const int SingleScore = 100;
private const int DoubleScore = 300;
private const int TripleScore = 500;
private const int TetrisScore = 800;
// ===== T-Spin分 =====
private const int TSpinMiniNoLines = 100;
private const int TSpinMiniSingle = 200;
private const int TSpinSingle = 800;
private const int TSpinDouble = 1200;
private const int TSpinTriple = 1600;
// ===== 下落分 =====
public const int SoftDropPerCell = 1;
public const int HardDropPerCell = 2;
// ===== Combo =====
private const int ComboBase = 50;
/// <summary>
/// 计算消行得分
/// </summary>
public static int CalculateLineClearScore(
int linesCleared,
int level,
TSpinType tSpinType,
int combo)
{
int baseScore = 0;
// 根据T-Spin类型和消行数确定基础分
if (tSpinType == TSpinType.Full)
{
baseScore = linesCleared switch
{
0 => TSpinMiniNoLines, // T-Spin 0行(放T-Spin但不消行)
1 => TSpinSingle,
2 => TSpinDouble,
3 => TSpinTriple,
_ => 0
};
}
else if (tSpinType == TSpinType.Mini)
{
baseScore = linesCleared switch
{
0 => TSpinMiniNoLines,
1 => TSpinMiniSingle,
_ => 0 // Mini T-Spin不支持消2行以上
};
}
else
{
baseScore = linesCleared switch
{
1 => SingleScore,
2 => DoubleScore,
3 => TripleScore,
4 => TetrisScore,
_ => 0
};
}
// 乘以等级
int totalScore = baseScore * level;
// 加上Combo奖励
if (combo > 0)
{
totalScore += ComboBase * combo * level;
}
return totalScore;
}
/// <summary>
/// 计算软降得分
/// </summary>
public static int CalculateSoftDropScore(int cellsDropped)
{
return cellsDropped * SoftDropPerCell;
}
/// <summary>
/// 计算硬降得分
/// </summary>
public static int CalculateHardDropScore(int cellsDropped)
{
return cellsDropped * HardDropPerCell;
}
}GDScript
## 完整计分系统
class_name ScoringSystem
# ===== 基础消行分 =====
const SINGLE_SCORE: int = 100
const DOUBLE_SCORE: int = 300
const TRIPLE_SCORE: int = 500
const TETRIS_SCORE: int = 800
# ===== T-Spin分 =====
const TSPIN_MINI_NO_LINES: int = 100
const TSPIN_MINI_SINGLE: int = 200
const TSPIN_SINGLE: int = 800
const TSPIN_DOUBLE: int = 1200
const TSPIN_TRIPLE: int = 1600
# ===== 下落分 =====
const SOFT_DROP_PER_CELL: int = 1
const HARD_DROP_PER_CELL: int = 2
# ===== Combo =====
const COMBO_BASE: int = 50
## 计算消行得分
static func calculate_line_clear_score(
lines_cleared: int,
level: int,
tspin_type: int,
combo: int
) -> int:
var base_score: int = 0
# 根据T-Spin类型和消行数确定基础分
if tspin_type == TSpinDetector.TSpinType.FULL:
match lines_cleared:
0: base_score = TSPIN_MINI_NO_LINES
1: base_score = TSPIN_SINGLE
2: base_score = TSPIN_DOUBLE
3: base_score = TSPIN_TRIPLE
_: base_score = 0
elif tspin_type == TSpinDetector.TSpinType.MINI:
match lines_cleared:
0: base_score = TSPIN_MINI_NO_LINES
1: base_score = TSPIN_MINI_SINGLE
_: base_score = 0
else:
match lines_cleared:
1: base_score = SINGLE_SCORE
2: base_score = DOUBLE_SCORE
3: base_score = TRIPLE_SCORE
4: base_score = TETRIS_SCORE
_: base_score = 0
# 乘以等级
var total_score: int = base_score * level
# 加上Combo奖励
if combo > 0:
total_score += COMBO_BASE * combo * level
return total_score
## 计算软降得分
static func calculate_soft_drop_score(cells_dropped: int) -> int:
return cells_dropped * SOFT_DROP_PER_CELL
## 计算硬降得分
static func calculate_hard_drop_score(cells_dropped: int) -> int:
return cells_dropped * HARD_DROP_PER_CELL5.6 分数弹出动画
当玩家消行时,分数不应该只是默默加到总分里——我们要在消除的位置弹出一个分数数字,飘上去然后消失,给玩家明确的反馈。
C
using Godot;
/// <summary>
/// 分数弹出动画——在消行位置显示飘动的分数
/// </summary>
public partial class ScorePopup : Node2D
{
private Label _label;
private float _lifetime = 1.0f;
public override void _Ready()
{
_label = new Label();
_label.AnchorLeft = 0.5f;
_label.AnchorTop = 0.5f;
_label.HorizontalAlignment = HorizontalAlignment.Center;
AddChild(_label);
}
/// <summary>
/// 显示分数弹出
/// </summary>
public void ShowScore(int score, Vector2 position, Color color)
{
GlobalPosition = position;
_label.Text = $"+{score}";
_label.AddThemeFontSizeOverride("font_size", 24);
_label.AddThemeColorOverride("font_color", color);
// 创建动画:向上飘动并淡出
var tween = CreateTween();
tween.TweenProperty(this, "position:y", Position.Y - 60, _lifetime);
tween.Parallel().TweenProperty(_label, "modulate:a", 0.0f, _lifetime);
tween.TweenCallback(Callable.From(QueueFree));
}
}GDScript
extends Node2D
## 分数弹出动画——在消行位置显示飘动的分数
var _label: Label
var _lifetime: float = 1.0
func _ready() -> void:
_label = Label.new()
_label.anchor_left = 0.5
_label.anchor_top = 0.5
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(_label)
## 显示分数弹出
func show_score(score: int, pos: Vector2, color: Color) -> void:
global_position = pos
_label.text = "+%d" % score
_label.add_theme_font_size_override("font_size", 24)
_label.add_theme_color_override("font_color", color)
# 创建动画:向上飘动并淡出
var tween = create_tween()
tween.tween_property(self, "position:y", position.y - 60, _lifetime)
tween.parallel().tween_property(_label, "modulate:a", 0.0, _lifetime)
tween.tween_callback(queue_free)5.7 完整的消行处理流程
把以上所有组件组合起来,完整的消行处理流程如下:
方块锁定
↓
检测满行 → 没有满行 → 重置Combo → 直接生成下一个方块
↓
有满行
↓
播放闪烁动画
↓
消除满行 + 上方行下移
↓
计算得分(消行分 + Combo分)
↓
显示分数弹出动画
↓
更新总分和等级
↓
生成下一个方块C
/// <summary>
/// GameBoard中的完整消行处理
/// </summary>
public void OnPieceLocked()
{
// 检测满行
var fullLines = GridNode.FindFullLines();
if (fullLines.Count == 0)
{
// 没有满行,重置Combo
_comboCount = 0;
// 直接生成下一个方块
SpawnNextPiece();
return;
}
// 有满行,增加Combo
_comboCount++;
// 播放消行动画(动画结束后会调用OnClearAnimationFinished)
_clearAnimator.StartAnimation(fullLines);
}
/// <summary>
/// 消行动画结束后的处理
/// </summary>
private void OnClearAnimationFinished(int linesCleared)
{
// 计算得分
int score = ScoringSystem.CalculateLineClearScore(
linesCleared,
GameManager.Instance.Level,
_lastTSpinType,
_comboCount
);
// 更新分数
GameManager.Instance.AddScore(score);
// 更新消行总数(用于升级)
GameManager.Instance.OnLinesCleared(linesCleared);
// 显示分数弹出
_ShowScorePopup(score, fullLines);
// 生成下一个方块
SpawnNextPiece();
}
private void _ShowScorePopup(int score, List<int> clearedLines)
{
// 在消除行的中间位置显示分数
int avgY = 0;
foreach (int line in clearedLines) avgY += line;
avgY /= clearedLines.Count;
Vector2 popupPos = new Vector2(
GameConstants.GridColumns * GameConstants.CellSize / 2f,
avgY * GameConstants.CellSize
);
var popup = new ScorePopup();
popup.ShowScore(score, popupPos, Colors.Yellow);
GridNode.AddChild(popup);
}GDScript
## GameBoard中的完整消行处理
func _on_piece_locked() -> void:
# 检测满行
var full_lines = grid_node.find_full_lines()
if full_lines.size() == 0:
# 没有满行,重置Combo
_combo_count = 0
# 直接生成下一个方块
_spawn_next_piece()
return
# 有满行,增加Combo
_combo_count += 1
# 播放消行动画
_clear_animator.start_animation(full_lines)
## 消行动画结束后的处理
func _on_clear_animation_finished(lines_cleared: int) -> void:
# 计算得分
var score = ScoringSystem.calculate_line_clear_score(
lines_cleared,
GameManager.level,
_last_tspin_type,
_combo_count
)
# 更新分数
GameManager.add_score(score)
# 更新消行总数
GameManager.on_lines_cleared(lines_cleared)
# 显示分数弹出
_show_score_popup(score)
# 生成下一个方块
_spawn_next_piece()
func _show_score_popup(score: int) -> void:
var popup = ScorePopup.new()
var pos = Vector2(
GameConstants.GRID_COLUMNS * GameConstants.CELL_SIZE / 2.0,
10 * GameConstants.CELL_SIZE
)
popup.show_score(score, pos, Color.YELLOW)
grid_node.add_child(popup)5.8 本章小结
| 组件 | 说明 |
|---|---|
| 满行检测 | 扫描每行,找到所有被填满的行 |
| 行消除 | 移除满行,上方行下移填补空缺 |
| 消行动画 | 闪烁效果让玩家清楚看到消行位置 |
| 计分系统 | 根据消行数、等级、T-Spin、Combo计算得分 |
| 分数弹出 | 飘动的分数数字,增强反馈感 |
| Combo | 连续消行的额外奖励机制 |
关键设计点:
- 消行动画播放期间,应该暂停方块下落,等动画结束再继续
- Combo计数在每次方块锁定时判断——消行则加1,不消行则归零
- T-Spin的得分远高于普通消行,鼓励玩家练习高阶技巧
下一章我们将实现关卡系统和速度递增。
