5. 计分与连击
2026/4/14大约 9 分钟
开心消消乐——计分与连击
什么是计分与连击?
计分系统就是游戏里的"成绩单"——每消除一组方块,分数就往上跳。连击系统则是"锦上添花"——如果你一次交换触发了多次连锁消除,分数会越滚越大。
想象你在打保龄球:
- 普通消除 = 打倒几个球瓶(基础分)
- 4消 = 打倒更多球瓶(高分)
- 连锁 = 打倒球瓶后,球又弹回来打倒更多(连击加分)
匹配检测算法
在计分之前,我们需要先找到哪些方块匹配了。匹配检测的思路是:扫描棋盘的每一行和每一列,找出连续3个或以上相同类型的方块。
水平匹配(横排)
从左到右扫描每一行,记录连续相同方块的起止位置:
行0: [红][红][红][蓝][蓝][绿][绿][绿]
↑ ↑
起止位置 起止位置
3个红色 3个绿色垂直匹配(竖排)
从上到下扫描每一列,记录连续相同方块的起止位置:
列0: 列1: 列2:
[红] [蓝] [绿]
[红] [蓝] [绿]
[红] ← 匹配 [蓝] ← 匹配 [绿] ← 匹配
[蓝] [绿] [黄]匹配数据结构
我们需要一个数据结构来存储"哪些位置需要被消除"。一个简单的方法是用一个坐标列表。
C
/// <summary>
/// 匹配信息
/// 记录一组匹配中所有方块的坐标
/// </summary>
public class MatchInfo
{
/// <summary>匹配中所有方块的坐标列表</summary>
public List<Vector2I> Cells { get; } = new List<Vector2I>();
/// <summary>匹配的方块类型</summary>
public PieceType MatchType { get; set; }
/// <summary>匹配的方向(水平/垂直)</summary>
public enum Direction { Horizontal, Vertical }
/// <summary>匹配方向</summary>
public Direction MatchDirection { get; set; }
/// <summary>匹配长度(几个方块)</summary>
public int Length => Cells.Count;
public MatchInfo(PieceType type, Direction dir)
{
MatchType = type;
MatchDirection = dir;
}
/// <summary>
/// 添加一个匹配的方块坐标
/// </summary>
public void AddCell(int row, int col)
{
Cells.Add(new Vector2I(row, col));
}
}GDScript
## 匹配信息
## 记录一组匹配中所有方块的坐标
class MatchInfo:
## 匹配中所有方块的坐标列表
var cells: Array[Vector2i] = []
## 匹配的方块类型
var match_type: int = 0
## 匹配方向枚举
enum Direction { HORIZONTAL, VERTICAL }
## 匹配方向
var match_direction: int = Direction.HORIZONTAL
## 匹配长度(几个方块)
var length: int:
get:
return cells.size()
func _init(piece_type: int, direction: int) -> void:
match_type = piece_type
match_direction = direction
## 添加一个匹配的方块坐标
func add_cell(row: int, col: int) -> void:
cells.append(Vector2i(row, col))完整匹配检测
C
/// <summary>
/// 查找棋盘上所有匹配
/// 扫描每一行和每一列,找出连续3个或以上相同类型的方块
/// </summary>
/// <returns>所有匹配的列表</returns>
public List<MatchInfo> FindMatches()
{
var allMatches = new List<MatchInfo>();
// ---- 检查水平匹配(逐行扫描)----
for (int row = 0; row < GameManager.ROWS; row++)
{
var matches = FindHorizontalMatches(row);
allMatches.AddRange(matches);
}
// ---- 检查垂直匹配(逐列扫描)----
for (int col = 0; col < GameManager.COLS; col++)
{
var matches = FindVerticalMatches(col);
allMatches.AddRange(matches);
}
// 打印匹配信息
if (allMatches.Count > 0)
{
GD.Print($"[GameBoard] 找到 {allMatches.Count} 组匹配!");
foreach (var match in allMatches)
{
string dir = match.MatchDirection == MatchInfo.Direction.Horizontal ? "横" : "竖";
GD.Print($" {dir}排 {match.Length}消: {string.Join(", ", match.Cells)}");
}
}
return allMatches;
}
/// <summary>
/// 在指定行中查找水平匹配
/// </summary>
private List<MatchInfo> FindHorizontalMatches(int row)
{
var matches = new List<MatchInfo>();
int startCol = 0;
PieceType currentType = _grid[row, 0];
for (int col = 1; col <= GameManager.COLS; col++)
{
PieceType checkType = col < GameManager.COLS
? _grid[row, col]
: PieceType.Empty; // 哨兵值,强制结束最后一组
if (checkType != currentType || col == GameManager.COLS)
{
// 类型变化了,检查刚才这组是否达到3个
int length = col - startCol;
if (length >= 3 && currentType != PieceType.Empty)
{
var match = new MatchInfo(currentType, MatchInfo.Direction.Horizontal);
for (int c = startCol; c < col; c++)
{
match.AddCell(row, c);
}
matches.Add(match);
}
// 开始新的一组
startCol = col;
currentType = checkType;
}
}
return matches;
}
/// <summary>
/// 在指定列中查找垂直匹配
/// </summary>
private List<MatchInfo> FindVerticalMatches(int col)
{
var matches = new List<MatchInfo>();
int startRow = 0;
PieceType currentType = _grid[0, col];
for (int row = 1; row <= GameManager.ROWS; row++)
{
PieceType checkType = row < GameManager.ROWS
? _grid[row, col]
: PieceType.Empty;
if (checkType != currentType || row == GameManager.ROWS)
{
int length = row - startRow;
if (length >= 3 && currentType != PieceType.Empty)
{
var match = new MatchInfo(currentType, MatchInfo.Direction.Vertical);
for (int r = startRow; r < row; r++)
{
match.AddCell(r, col);
}
matches.Add(match);
}
startRow = row;
currentType = checkType;
}
}
return matches;
}GDScript
## 查找棋盘上所有匹配
## 扫描每一行和每一列,找出连续3个或以上相同类型的方块
func find_matches() -> Array[MatchInfo]:
var all_matches: Array[MatchInfo] = []
# ---- 检查水平匹配(逐行扫描)----
for row in range(GameManager.ROWS):
var matches := _find_horizontal_matches(row)
all_matches.append_array(matches)
# ---- 检查垂直匹配(逐列扫描)----
for col in range(GameManager.COLS):
var matches := _find_vertical_matches(col)
all_matches.append_array(matches)
# 打印匹配信息
if all_matches.size() > 0:
print("[GameBoard] 找到 %d 组匹配!" % all_matches.size())
for match_info in all_matches:
var dir_str: String = "横" if match_info.match_direction == MatchInfo.Direction.HORIZONTAL else "竖"
print(" %s排 %d消" % [dir_str, match_info.length])
return all_matches
## 在指定行中查找水平匹配
func _find_horizontal_matches(row: int) -> Array[MatchInfo]:
var matches: Array[MatchInfo] = []
var start_col: int = 0
var current_type: int = grid[row][0]
for col in range(1, GameManager.COLS + 1):
var check_type: int = grid[row][col] if col < GameManager.COLS \
else GameManager.PieceType.EMPTY
if check_type != current_type or col == GameManager.COLS:
# 类型变化了,检查刚才这组是否达到3个
var length: int = col - start_col
if length >= 3 and current_type != GameManager.PieceType.EMPTY:
var match_info := MatchInfo.new(current_type, MatchInfo.Direction.HORIZONTAL)
for c in range(start_col, col):
match_info.add_cell(row, c)
matches.append(match_info)
# 开始新的一组
start_col = col
current_type = check_type
return matches
## 在指定列中查找垂直匹配
func _find_vertical_matches(col: int) -> Array[MatchInfo]:
var matches: Array[MatchInfo] = []
var start_row: int = 0
var current_type: int = grid[0][col]
for row in range(1, GameManager.ROWS + 1):
var check_type: int = grid[row][col] if row < GameManager.ROWS \
else GameManager.PieceType.EMPTY
if check_type != current_type or row == GameManager.ROWS:
var length: int = row - start_row
if length >= 3 and current_type != GameManager.PieceType.EMPTY:
var match_info := MatchInfo.new(current_type, MatchInfo.Direction.VERTICAL)
for r in range(start_row, row):
match_info.add_cell(r, col)
matches.append(match_info)
start_row = row
current_type = check_type
return matches消除处理
找到匹配后,需要:
- 计算分数
- 播放消除动画
- 从棋盘数据中移除方块
- 触发下落填充
分数计算规则
| 消除类型 | 基础分 | 说明 |
|---|---|---|
| 3消 | 50分 | 最基础的消除 |
| 4消 | 150分 | 3 x 50 |
| 5消 | 300分 | 6 x 50 |
连击倍率:每次连锁,分数乘以连击数。
| 连击次数 | 倍率 | 示例(3消50分) |
|---|---|---|
| 第1次 | x1 | 50分 |
| 第2次 | x2 | 100分 |
| 第3次 | x3 | 150分 |
| 第4次+ | x4 | 200分 |
C
/// <summary>
/// 处理匹配消除
/// </summary>
public async void ProcessMatches(List<MatchInfo> matches)
{
_gameManager.CurrentState = GameState.Removing;
int totalScore = 0;
int combo = _gameManager.GetCombo() + 1;
// 合并所有匹配的位置(去重)
var cellsToRemove = new HashSet<Vector2I>();
foreach (var match in matches)
{
foreach (var cell in match.Cells)
{
cellsToRemove.Add(cell);
}
// 计算这组匹配的分数
int baseScore = match.Length * 50;
totalScore += baseScore;
}
// 应用连击倍率
int comboMultiplier = Mathf.Min(combo, 4); // 最高4倍
totalScore *= comboMultiplier;
GD.Print($"[GameBoard] 消除 {cellsToRemove.Count} 个方块," +
$"基础分 {totalScore},连击 x{comboMultiplier}");
// 播放消除动画
await PlayRemoveAnimation(cellsToRemove);
// 从棋盘数据中移除
foreach (var cell in cellsToRemove)
{
RemovePiece(cell.X, cell.Y);
}
// 更新分数
_gameManager.AddScore(totalScore);
// 触发下落
ApplyGravity();
}
/// <summary>
/// 从棋盘上移除指定位置的方块
/// </summary>
private void RemovePiece(int row, int col)
{
// 从数据中标记为空
_grid[row, col] = PieceType.Empty;
// 移除节点
if (_pieces[row, col] != null)
{
_pieces[row, col].QueueFree();
_pieces[row, col] = null;
}
}
/// <summary>
/// 播放消除动画
/// 所有被消除的方块缩小并淡出
/// </summary>
private async Task PlayRemoveAnimation(HashSet<Vector2I> cells)
{
foreach (var cell in cells)
{
Piece piece = _pieces[cell.X, cell.Y];
if (piece != null)
{
// 缩小并淡出
Tween tween = CreateTween();
tween.TweenProperty(piece, "scale", Vector2.Zero, 0.2f);
tween.Parallel().TweenProperty(piece, "modulate:a", 0f, 0.2f);
}
}
// 等待动画完成
await ToSignal(GetTree().CreateTimer(0.25), SceneTree.TimerSignalName.Timeout);
}GDScript
## 处理匹配消除
func _process_matches(matches: Array[MatchInfo]) -> void:
_game_manager.current_state = GameManager.GameState.REMOVING
var total_score: int = 0
var combo: int = _game_manager.get_combo() + 1
# 合并所有匹配的位置(去重)
var cells_to_remove: Dictionary = {} # 用字典去重
for match_info in matches:
for cell in match_info.cells:
cells_to_remove[cell] = true
# 计算这组匹配的分数
var base_score: int = match_info.length * 50
total_score += base_score
# 应用连击倍率
var combo_multiplier: int = mini(combo, 4) # 最高4倍
total_score *= combo_multiplier
print("[GameBoard] 消除 %d 个方块,基础分 %d,连击 x%d" % [
cells_to_remove.size(), total_score, combo_multiplier
])
# 播放消除动画
await _play_remove_animation(cells_to_remove.keys())
# 从棋盘数据中移除
for cell in cells_to_remove.keys():
_remove_piece(cell.x, cell.y)
# 更新分数
_game_manager.add_score(total_score)
# 触发下落
apply_gravity()
## 从棋盘上移除指定位置的方块
func _remove_piece(row: int, col: int) -> void:
# 从数据中标记为空
grid[row][col] = GameManager.PieceType.EMPTY
# 移除节点
if pieces[row][col] != null:
pieces[row][col].queue_free()
pieces[row][col] = null
## 播放消除动画
## 所有被消除的方块缩小并淡出
func _play_remove_animation(cells: Array) -> void:
for cell in cells:
var piece: Piece = pieces[cell.x][cell.y]
if piece != null:
# 缩小并淡出
var tween := create_tween()
tween.tween_property(piece, "scale", Vector2.ZERO, 0.2)
tween.parallel().tween_property(piece, "modulate:a", 0.0, 0.2)
# 等待动画完成
await get_tree().create_timer(0.25).timeout分数飘字
每次消除时,在消除位置显示一个"飘上去然后消失"的分数数字,这叫飘字效果。它能给玩家即时的正反馈。
C
/// <summary>
/// 分数飘字场景(在编辑器中创建一个 Label 场景)
/// </summary>
[Export] public PackedScene FloatingScoreScene { get; set; }
/// <summary>
/// 在指定位置显示飘字
/// </summary>
/// <param name="worldPos">世界坐标位置</param>
/// <param name="text">显示的文字</param>
/// <param name="color">文字颜色</param>
public void ShowFloatingScore(Vector2 worldPos, string text, Color color)
{
if (FloatingScoreScene == null) return;
var label = FloatingScoreScene.Instantiate<Label>();
label.Text = text;
label.Position = worldPos;
label.Modulate = color;
AddChild(label);
// 飘字动画:向上飘 + 淡出
Tween tween = CreateTween();
tween.TweenProperty(label, "position:y", worldPos.Y - 60, 0.8f);
tween.Parallel().TweenProperty(label, "modulate:a", 0f, 0.8f);
tween.TweenCallback(Callable.From(() => label.QueueFree()));
}GDScript
## 分数飘字场景(在编辑器中创建一个 Label 场景)
@export var floating_score_scene: PackedScene
## 在指定位置显示飘字
## world_pos: 世界坐标位置, text: 显示的文字, color: 文字颜色
func show_floating_score(world_pos: Vector2, text: String, color: Color) -> void:
if floating_score_scene == null:
return
var label: Label = floating_score_scene.instantiate()
label.text = text
label.position = world_pos
label.modulate = color
add_child(label)
# 飘字动画:向上飘 + 淡出
var tween := create_tween()
tween.tween_property(label, "position:y", world_pos.y - 60, 0.8)
tween.parallel().tween_property(label, "modulate:a", 0.0, 0.8)
tween.tween_callback(label.queue_free)连击提示
当连击数达到一定值时,显示特殊的提示文字。
| 连击数 | 提示文字 | 颜色 |
|---|---|---|
| 2 | "不错!" | 黄色 |
| 3 | "厉害!" | 橙色 |
| 4+ | "太强了!" | 红色 |
C
/// <summary>
/// 根据连击数获取提示文字和颜色
/// </summary>
public (string text, Color color) GetComboText(int combo)
{
return combo switch
{
2 => ("不错!", new Color("FFDD44")),
3 => ("厉害!", new Color("FF8800")),
>= 4 => ("太强了!", new Color("FF4444")),
_ => ("", Colors.White)
};
}
/// <summary>
/// 在消除完成后显示连击提示
/// </summary>
public void ShowComboFeedback(int combo)
{
if (combo < 2) return;
var (text, color) = GetComboText(combo);
// 在棋盘中央显示连击提示
Vector2 center = new Vector2(
GameManager.BOARD_WIDTH / 2f,
GameManager.BOARD_HEIGHT / 2f
);
ShowFloatingScore(center, text, color);
}GDScript
## 根据连击数获取提示文字和颜色
func _get_combo_text(combo: int) -> Dictionary:
match combo:
2:
return {"text": "不错!", "color": Color("FFDD44")}
3:
return {"text": "厉害!", "color": Color("FF8800")}
_:
if combo >= 4:
return {"text": "太强了!", "color": Color("FF4444")}
return {"text": "", "color": Color.WHITE}
## 在消除完成后显示连击提示
func _show_combo_feedback(combo: int) -> void:
if combo < 2:
return
var info: Dictionary = _get_combo_text(combo)
var text: String = info["text"]
var color: Color = info["color"]
# 在棋盘中央显示连击提示
var center: Vector2 = Vector2(
GameManager.BOARD_WIDTH / 2.0,
GameManager.BOARD_HEIGHT / 2.0
)
show_floating_score(center, text, color)本章小结
| 完成项 | 说明 |
|---|---|
| 匹配检测 | 扫描行列,找出3个及以上连续同色方块 |
| 匹配数据 | MatchInfo 存储匹配的坐标、类型、方向 |
| 分数计算 | 3消50分、4消150分、5消300分 |
| 连击倍率 | 连锁消除时,分数乘以连击数(最高4倍) |
| 消除动画 | 方块缩小并淡出 |
| 分数飘字 | 在消除位置显示向上飘动的分数数字 |
| 连击提示 | 连击达到2次以上时显示鼓励文字 |
现在游戏的核心循环已经完整了——交换、匹配、消除、下落、连锁,每次都有分数反馈。下一章我们将实现关卡系统,让游戏有明确的目标和进度。
