3. 棋盘系统
开心消消乐——棋盘系统
什么是棋盘系统?
棋盘系统是三消游戏的"桌子"——方块就摆在这张桌子上。它需要做三件事:
- 存储:记住每个格子上放的是什么颜色的方块
- 渲染:把方块按正确的位置画到屏幕上
- 生成:游戏开始时,随机填满整个棋盘,但不能出现"已经凑好三个"的情况
你可以把棋盘想象成一个8行8列的鸡蛋盒——每个格子刚好放一个鸡蛋(方块),鸡蛋有红蓝绿黄紫五种颜色。
二维数组:棋盘的数据结构
在代码中,我们用一个二维数组来表示棋盘。什么是二维数组?你可以把它想象成一个Excel表格——有行有列,每个格子(单元格)里存着一个值。
| 第0列 | 第1列 | 第2列 | 第3列 | 第4列 | 第5列 | 第6列 | 第7列 | |
|---|---|---|---|---|---|---|---|---|
| 第0行 | 红 | 蓝 | 绿 | 黄 | 紫 | 红 | 蓝 | 绿 |
| 第1行 | 黄 | 紫 | 红 | 蓝 | 绿 | 黄 | 紫 | 红 |
| 第2行 | 绿 | 红 | 蓝 | 紫 | 红 | 绿 | 黄 | 紫 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 第7行 | 紫 | 黄 | 红 | 绿 | 蓝 | 紫 | 红 | 蓝 |
坐标系统
棋盘使用 (行, 列) 的方式来定位,注意:
- 行(Row) 从上往下数,第0行在最上面,第7行在最下面
- 列(Col) 从左往右数,第0列在最左边,第7列在最右边
这和我们日常使用的数学坐标系(x向右,y向上)不同,但和Excel表格的定位方式一致。
坐标转换
棋盘上的逻辑坐标(行、列)需要转换成屏幕上的像素坐标,才能正确地把方块显示出来。
像素X = 列号 × 方块大小 + 方块大小的一半
像素Y = 行号 × 方块大小 + 方块大小的一半为什么要加"方块大小的一半"?因为 Godot 中节点的 Position 是指中心点的位置。假设方块大小是64像素,那么第0列的方块中心应该在32像素处(0 × 64 + 32)。
/// <summary>
/// 将棋盘坐标(行列)转换为屏幕像素坐标
/// </summary>
/// <param name="row">行号</param>
/// <param name="col">列号</param>
/// <returns>屏幕上的像素坐标</returns>
public Vector2 GridToPixel(int row, int col)
{
float x = col * GameManager.PIECE_SIZE + GameManager.PIECE_SIZE / 2.0f;
float y = row * GameManager.PIECE_SIZE + GameManager.PIECE_SIZE / 2.0f;
return new Vector2(x, y);
}
/// <summary>
/// 将屏幕像素坐标转换为棋盘坐标(行列)
/// </summary>
/// <param name="pixelPos">屏幕像素坐标</param>
/// <param name="row">输出:行号</param>
/// <param name="col">输出:列号</param>
public void PixelToGrid(Vector2 pixelPos, out int row, out int col)
{
col = (int)(pixelPos.X / GameManager.PIECE_SIZE);
row = (int)(pixelPos.Y / GameManager.PIECE_SIZE);
// 确保行列号不超出棋盘范围
col = Mathf.Clamp(col, 0, GameManager.COLS - 1);
row = Mathf.Clamp(row, 0, GameManager.ROWS - 1);
}## 将棋盘坐标(行列)转换为屏幕像素坐标
func grid_to_pixel(row: int, col: int) -> Vector2:
var x: float = col * GameManager.PIECE_SIZE + GameManager.PIECE_SIZE / 2.0
var y: float = row * GameManager.PIECE_SIZE + GameManager.PIECE_SIZE / 2.0
return Vector2(x, y)
## 将屏幕像素坐标转换为棋盘坐标(行列)
func pixel_to_grid(pixel_pos: Vector2) -> Vector2i:
var col: int = int(pixel_pos.x / GameManager.PIECE_SIZE)
var row: int = int(pixel_pos.y / GameManager.PIECE_SIZE)
# 确保行列号不超出棋盘范围
col = clampi(col, 0, GameManager.COLS - 1)
row = clampi(row, 0, GameManager.ROWS - 1)
return Vector2i(row, col)GameBoard 棋盘管理器
GameBoard 负责管理棋盘上的所有方块。它是"鸡蛋盒"的管理员——知道哪个格子放了什么颜色的鸡蛋。
棋盘数据结构
我们使用一个二维数组 grid 来存储每个格子的方块类型:
using Godot;
using System;
/// <summary>
/// 棋盘管理器
/// 管理8x8的方块网格,负责生成、渲染和操作方块
/// </summary>
public partial class GameBoard : Control
{
/// <summary>
/// 方块场景(在编辑器中拖拽 Piece.tscn 到这里)
/// </summary>
[Export] public PackedScene PieceScene { get; set; }
/// <summary>
/// 游戏管理器引用
/// </summary>
private GameManager _gameManager;
/// <summary>
/// 棋盘数据:grid[行][列] = 方块类型
/// 这是一个8x8的二维数组
/// </summary>
private PieceType[,] _grid = new PieceType[GameManager.ROWS, GameManager.COLS];
/// <summary>
/// 方块节点数组:存储棋盘上每个方块的节点引用
/// 用于访问和操作具体的方块节点
/// </summary>
private Piece[,] _pieces = new Piece[GameManager.ROWS, GameManager.COLS];
public override void _Ready()
{
// 设置棋盘区域的大小
CustomMinimumSize = new Vector2(GameManager.BOARD_WIDTH, GameManager.BOARD_HEIGHT);
Size = new Vector2(GameManager.BOARD_WIDTH, GameManager.BOARD_HEIGHT);
// 获取 GameManager 引用
_gameManager = GetNode<GameManager>("/root/Main/GameManager");
// 初始化棋盘
InitializeBoard();
}
/// <summary>
/// 初始化棋盘
/// 填满方块,确保没有初始匹配
/// </summary>
public void InitializeBoard()
{
GD.Print("[GameBoard] 开始初始化棋盘...");
for (int row = 0; row < GameManager.ROWS; row++)
{
for (int col = 0; col < GameManager.COLS; col++)
{
// 随机生成一个不会产生匹配的方块类型
PieceType type = GetSafeRandomType(row, col);
_grid[row, col] = type;
// 创建方块节点并添加到场景中
Piece piece = PieceScene.Instantiate<Piece>();
piece.Initialize(type, row, col);
piece.SetGridPosition(row, col, GameManager.PIECE_SIZE);
piece.PieceClicked += OnPieceClicked;
AddChild(piece);
_pieces[row, col] = piece;
}
}
GD.Print("[GameBoard] 棋盘初始化完成!");
}
}extends Control
## 方块场景(在编辑器中拖拽 Piece.tscn 到这里)
@export var piece_scene: PackedScene
## 游戏管理器引用
var _game_manager: GameManager
## 棋盘数据:grid[行][列] = 方块类型
## 这是一个8x8的二维数组
var grid: Array = [] # Array[Array[PieceType]]
## 方块节点数组:存储棋盘上每个方块的节点引用
var pieces: Array = [] # Array[Array[Piece]]
func _ready() -> void:
# 设置棋盘区域的大小
custom_minimum_size = Vector2(GameManager.BOARD_WIDTH, GameManager.BOARD_HEIGHT)
size = Vector2(GameManager.BOARD_WIDTH, GameManager.BOARD_HEIGHT)
# 获取 GameManager 引用
_game_manager = get_node("/root/Main/GameManager")
# 初始化棋盘
initialize_board()
## 初始化棋盘
## 填满方块,确保没有初始匹配
func initialize_board() -> void:
print("[GameBoard] 开始初始化棋盘...")
# 初始化二维数组
grid.clear()
pieces.clear()
for row in range(GameManager.ROWS):
grid.append([])
pieces.append([])
for col in range(GameManager.COLS):
grid[row].append(0)
pieces[row].append(null)
for row in range(GameManager.ROWS):
for col in range(GameManager.COLS):
# 随机生成一个不会产生匹配的方块类型
var piece_type: int = _get_safe_random_type(row, col)
grid[row][col] = piece_type
# 创建方块节点并添加到场景中
var piece: Piece = piece_scene.instantiate()
piece.initialize(piece_type, row, col)
piece.set_grid_position(row, col, GameManager.PIECE_SIZE)
piece.piece_clicked.connect(_on_piece_clicked)
add_child(piece)
pieces[row][col] = piece
print("[GameBoard] 棋盘初始化完成!")无初始匹配的随机生成
这是棋盘系统中最关键的逻辑。如果游戏一开始就有三个同色方块排成一排,那玩家还没操作就"白捡"分数了,这不公平。
"安全随机"的思路
想象你在往鸡蛋盒里放鸡蛋。每放一个鸡蛋之前,你要检查一下:
- 往左看两个:如果我放红色,左边两个是不是也是红色?如果是,那三个红色就凑齐了,不行!
- 往上看两个:如果我放红色,上面两个是不是也是红色?如果是,也不行!
如果某颜色会导致匹配,就换一个颜色。这就像你穿衣服时检查"今天穿红色会不会和昨天撞衫"。
/// <summary>
/// 随机数生成器
/// </summary>
private static readonly Random _random = new Random();
/// <summary>
/// 获取一个"安全"的随机方块类型
/// "安全"意味着放置这个类型不会产生初始匹配
/// </summary>
/// <param name="row">目标行</param>
/// <param name="col">目标列</param>
/// <returns>安全的方块类型</returns>
private PieceType GetSafeRandomType(int row, int col)
{
// 获取所有可能的方块类型(排除空位)
var availableTypes = new List<PieceType>();
for (int i = 0; i < GameManager.PIECE_TYPES; i++)
{
availableTypes.Add((PieceType)i);
}
// 打乱顺序,优先尝试随机类型
ShuffleList(availableTypes);
foreach (var type in availableTypes)
{
if (IsSafePlacement(row, col, type))
{
return type;
}
}
// 如果所有类型都不安全(极端情况),返回随机类型
return (PieceType)_random.Next(GameManager.PIECE_TYPES);
}
/// <summary>
/// 检查在 (row, col) 放置指定类型是否安全
/// </summary>
private bool IsSafePlacement(int row, int col, PieceType type)
{
// ---- 检查水平方向(往左看两个)----
// 情况:左边两个都是相同类型
// [已填][已填][待填] ← 如果三个相同就匹配了
if (col >= 2
&& _grid[row, col - 1] == type
&& _grid[row, col - 2] == type)
{
return false; // 不安全!
}
// ---- 检查垂直方向(往上看两个)----
// 情况:上面两个都是相同类型
// [已填]
// [已填]
// [待填] ← 如果三个相同就匹配了
if (row >= 2
&& _grid[row - 1, col] == type
&& _grid[row - 2, col] == type)
{
return false; // 不安全!
}
return true; // 安全!
}
/// <summary>
/// Fisher-Yates 洗牌算法
/// 把列表随机打乱顺序
/// </summary>
private void ShuffleList<T>(List<T> list)
{
for (int i = list.Count - 1; i > 0; i--)
{
int j = _random.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}## 随机数生成器
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
_rng.randomize()
# ... 其他初始化代码 ...
## 获取一个"安全"的随机方块类型
## "安全"意味着放置这个类型不会产生初始匹配
func _get_safe_random_type(row: int, col: int) -> int:
# 获取所有可能的方块类型(排除空位)
var available_types: Array = []
for i in range(GameManager.PIECE_TYPES):
available_types.append(i)
# 打乱顺序,优先尝试随机类型
available_types.shuffle()
for piece_type in available_types:
if _is_safe_placement(row, col, piece_type):
return piece_type
# 如果所有类型都不安全(极端情况),返回随机类型
return _rng.randi_range(0, GameManager.PIECE_TYPES - 1)
## 检查在 (row, col) 放置指定类型是否安全
func _is_safe_placement(row: int, col: int, piece_type: int) -> bool:
# ---- 检查水平方向(往左看两个)----
# 情况:左边两个都是相同类型
# [已填][已填][待填] ← 如果三个相同就匹配了
if col >= 2:
if grid[row][col - 1] == piece_type and grid[row][col - 2] == piece_type:
return false # 不安全!
# ---- 检查垂直方向(往上看两个)----
# 情况:上面两个都是相同类型
# [已填]
# [已填]
# [待填] ← 如果三个相同就匹配了
if row >= 2:
if grid[row - 1][col] == piece_type and grid[row - 2][col] == piece_type:
return false # 不安全!
return true # 安全!棋盘渲染
棋盘的渲染就是把数据(二维数组)变成玩家看到的画面(彩色方块)。每个方块是一个独立的 Piece 节点,它们都是 GameBoard 的子节点。
方块生成的视觉效果
为了让游戏开始时有一个漂亮的"掉落"效果,我们可以让方块从棋盘上方掉下来,而不是直接出现在最终位置。
/// <summary>
/// 用掉落动画初始化棋盘
/// 每列的方块依次从上方掉落
/// </summary>
public void InitializeBoardWithAnimation()
{
GD.Print("[GameBoard] 带动画初始化棋盘...");
for (int col = 0; col < GameManager.COLS; col++)
{
for (int row = 0; row < GameManager.ROWS; row++)
{
PieceType type = GetSafeRandomType(row, col);
_grid[row, col] = type;
Piece piece = PieceScene.Instantiate<Piece>();
piece.Initialize(type, row, col);
piece.PieceClicked += OnPieceClicked;
AddChild(piece);
_pieces[row, col] = piece;
// 先把方块放在棋盘上方(屏幕外)
Vector2 targetPos = GridToPixel(row, col);
piece.Position = new Vector2(targetPos.X, -GameManager.PIECE_SIZE);
// 使用 Tween 制作掉落动画
Tween tween = CreateTween();
// 每列延迟一点时间,形成"波浪"效果
float delay = col * 0.05f + row * 0.02f;
tween.TweenProperty(piece, "position", targetPos, 0.4f)
.SetDelay(delay)
.SetTrans(Tween.TransitionType.Bounce)
.SetEase(Tween.EaseType.Out);
}
}
GD.Print("[GameBoard] 棋盘初始化完成(带动画)!");
}## 用掉落动画初始化棋盘
## 每列的方块依次从上方掉落
func initialize_board_with_animation() -> void:
print("[GameBoard] 带动画初始化棋盘...")
for col in range(GameManager.COLS):
for row in range(GameManager.ROWS):
var piece_type: int = _get_safe_random_type(row, col)
grid[row][col] = piece_type
var piece: Piece = piece_scene.instantiate()
piece.initialize(piece_type, row, col)
piece.piece_clicked.connect(_on_piece_clicked)
add_child(piece)
pieces[row][col] = piece
# 先把方块放在棋盘上方(屏幕外)
var target_pos: Vector2 = grid_to_pixel(row, col)
piece.position = Vector2(target_pos.x, -GameManager.PIECE_SIZE)
# 使用 Tween 制作掉落动画
var tween := create_tween()
# 每列延迟一点时间,形成"波浪"效果
var delay: float = col * 0.05 + row * 0.02
tween.tween_property(piece, "position", target_pos, 0.4) \
.set_delay(delay) \
.set_trans(Tween.TransitionType.BOUNCE) \
.set_ease(Tween.EaseType.OUT)
print("[GameBoard] 棋盘初始化完成(带动画)!")获取和设置方块
棋盘管理器还需要提供一些工具方法,让其他脚本可以查询和修改棋盘上的方块。
/// <summary>
/// 获取指定位置的方块类型
/// </summary>
public PieceType GetPieceType(int row, int col)
{
// 边界检查,防止数组越界
if (row < 0 || row >= GameManager.ROWS
|| col < 0 || col >= GameManager.COLS)
{
return PieceType.Empty;
}
return _grid[row, col];
}
/// <summary>
/// 获取指定位置的方块节点
/// </summary>
public Piece GetPiece(int row, int col)
{
if (row < 0 || row >= GameManager.ROWS
|| col < 0 || col >= GameManager.COLS)
{
return null;
}
return _pieces[row, col];
}
/// <summary>
/// 交换两个位置的方块(数据层)
/// </summary>
public void SwapPieces(int row1, int col1, int row2, int col2)
{
// 交换数据
PieceType tempType = _grid[row1, col1];
_grid[row1, col1] = _grid[row2, col2];
_grid[row2, col2] = tempType;
// 交换节点引用
Piece tempPiece = _pieces[row1, col1];
_pieces[row1, col1] = _pieces[row2, col2];
_pieces[row2, col2] = tempPiece;
// 更新方块的行列信息
if (_pieces[row1, col1] != null)
{
_pieces[row1, col1].Row = row1;
_pieces[row1, col1].Col = col1;
}
if (_pieces[row2, col2] != null)
{
_pieces[row2, col2].Row = row2;
_pieces[row2, col2].Col = col2;
}
}
/// <summary>
/// 检查指定位置是否在棋盘范围内
/// </summary>
public bool IsInBounds(int row, int col)
{
return row >= 0 && row < GameManager.ROWS
&& col >= 0 && col < GameManager.COLS;
}## 获取指定位置的方块类型
func get_piece_type(row: int, col: int) -> int:
# 边界检查,防止数组越界
if row < 0 or row >= GameManager.ROWS \
or col < 0 or col >= GameManager.COLS:
return GameManager.PieceType.EMPTY
return grid[row][col]
## 获取指定位置的方块节点
func get_piece(row: int, col: int) -> Piece:
if row < 0 or row >= GameManager.ROWS \
or col < 0 or col >= GameManager.COLS:
return null
return pieces[row][col]
## 交换两个位置的方块(数据层)
func swap_pieces(row1: int, col1: int, row2: int, col2: int) -> void:
# 交换数据
var temp_type = grid[row1][col1]
grid[row1][col1] = grid[row2][col2]
grid[row2][col2] = temp_type
# 交换节点引用
var temp_piece = pieces[row1][col1]
pieces[row1][col1] = pieces[row2][col2]
pieces[row2][col2] = temp_piece
# 更新方块的行列信息
if pieces[row1][col1] != null:
pieces[row1][col1].row = row1
pieces[row1][col1].col = col1
if pieces[row2][col2] != null:
pieces[row2][col2].row = row2
pieces[row2][col2].col = col2
## 检查指定位置是否在棋盘范围内
func is_in_bounds(row: int, col: int) -> bool:
return row >= 0 and row < GameManager.ROWS \
and col >= 0 and col < GameManager.COLS本章小结
| 完成项 | 说明 |
|---|---|
| 二维数组 | 用 grid[行][列] 存储棋盘数据 |
| 坐标转换 | 棋盘坐标(行列)和屏幕像素坐标互转 |
| 安全随机 | 确保初始棋盘没有三消匹配 |
| 棋盘渲染 | 生成方块节点并放到正确位置 |
| 掉落动画 | 用 Tween 实现方块从上方掉落的效果 |
| 工具方法 | 获取/设置方块、交换方块、边界检查 |
现在我们已经有了一个漂亮的、没有"作弊"的初始棋盘。下一章,我们将实现玩家操作方块的核心逻辑——点击选中、交换、下落,让方块真正"动"起来。
