1. 核心玩法设计
1. 俄罗斯方块——核心玩法
本章你将掌握
可以学到的 Godot 技能
| 技能 | 说明 |
|---|---|
| 二维数组操作 | 用 Array 表示 10×20 的游戏网格,读写格子状态 |
| 旋转算法 | 矩阵转置 + 翻转,实现方块的 4 种旋转状态 |
| 碰撞检测(纯逻辑) | 不依赖物理引擎,用代码判断方块是否越界或重叠 |
| 消行逻辑 | 检测满行、删除行、上方行下移 |
| 计分与等级系统 | 消行得分、等级提升、速度递增 |
| 状态机 | 管理菜单/游戏中/暂停/结束四种状态 |
| Timer 节点 | 控制方块自动下落的时间间隔 |
| 输入映射 | 在 Project Settings 中配置键盘操作 |
关键 Node 节点
TetrisGame(Node2D)
├── GameBoard(Node2D) ← 游戏主区域
│ └── TileMap ← 渲染已落定的方块
├── CurrentPiece(Node2D) ← 当前正在下落的方块
├── NextPiecePreview(Node2D) ← 下一个方块预览
├── HoldPiece(Node2D) ← 暂存方块
├── GameTimer(Timer) ← 控制方块下落速度
├── UI(CanvasLayer)
│ ├── ScoreLabel(Label) ← 分数显示
│ ├── LevelLabel(Label) ← 等级显示
│ ├── LinesLabel(Label) ← 消行数显示
│ ├── PauseMenu(Control) ← 暂停菜单
│ └── GameOverScreen(Control)← 游戏结束画面
└── AudioManager(Node) ← 音效管理游戏核心节点
点击节点可跳转到对应文档查看详细说明。
游戏系统结构图
┌─────────────────────────────────────────────────────┐
│ 俄罗斯方块系统架构 │
├─────────────────────────────────────────────────────┤
│ │
│ 输入系统 游戏逻辑 渲染系统 │
│ ┌────────┐ ┌──────────┐ ┌──────────┐ │
│ │键盘输入 │──────▶│ 方块控制器│─────▶│ TileMap │ │
│ └────────┘ └────┬─────┘ └──────────┘ │
│ │ │
│ ┌────▼─────┐ ┌──────────┐ │
│ │ 网格管理器│─────▶│ UI 系统 │ │
│ └────┬─────┘ └──────────┘ │
│ │ │
│ ┌────▼─────┐ ┌──────────┐ │
│ │ 消行检测 │─────▶│ 音效系统 │ │
│ └────┬─────┘ └──────────┘ │
│ │ │
│ ┌────▼─────┐ │
│ │ 计分系统 │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────┘1.1 俄罗斯方块是什么?
想象你手里有一堆积木,它们不断从天上掉下来。你需要把这些积木一块一块地拼在一起,拼满一整行,这一行就会消失,你就能得分。如果积木堆得太高,碰到了天花板,游戏就结束了。
这就是俄罗斯方块(Tetris)——一款诞生于1984年的经典益智游戏。它的规则极其简单,几乎任何人都能在30秒内学会,但要玩到精通却需要大量的练习和策略。
为什么选择俄罗斯方块作为入门项目?
| 原因 | 说明 |
|---|---|
| 规则简单 | 没有复杂的剧情,核心机制一目了然 |
| 技术全面 | 涵盖数组操作、碰撞检测、状态机、计时器等核心编程概念 |
| 可扩展性强 | 从最基础的版本到加入各种高级机制,难度可以逐步提升 |
| 成就感高 | 短时间内就能看到完整的可玩游戏 |
1.2 游戏的核心循环
每一个游戏都有一个核心循环(Core Loop)——玩家反复执行的"动作→反馈→奖励"过程。俄罗斯方块的核心循环非常清晰:
┌─────────────────────────────────────────────┐
│ │
│ 生成新方块 → 玩家操控方块 → 方块落定 │
│ ↑ │ │
│ │ ↓ │
│ 游戏继续 ←── 消行/得分 ←── 检测满行 │
│ │
└─────────────────────────────────────────────┘用大白话来说就是:
- 生成方块:系统从七种方块中随机选一个,放在游戏区域的最上方
- 玩家操控:玩家可以左右移动、旋转、加速下落、或者直接让方块落到底部
- 方块落定:方块碰到地面或者其他已落定的方块后,就固定在原地
- 检测满行:系统检查有没有哪一行被完全填满了
- 消行得分:如果有满行,就消除它们并给玩家加分
- 循环:回到第1步,生成下一个方块
如果方块落定时,最顶部有方块超出了游戏区域,游戏就结束了。
1.3 游戏区域——网格系统
俄罗斯方块的游戏区域是一个网格(Grid)。你可以把它想象成一个Excel表格,每个格子要么是空的,要么被一个方块填满。
标准俄罗斯方块的网格大小是 10列 x 20行。
列号: 0 1 2 3 4 5 6 7 8 9
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
行 0 │ │ │ │ │ │ │ │ │ │ │ ← 方块从这里出现
├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
行 1 │ │ │ │ │ │ │ │ │ │ │
├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
行 2 │ │ │ │ │ │ │ │ │ │ │
├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
│ . . . . . . . . . . │
│ . . . . . . . . . . │
│ . . . . . . . . . . │
├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
行19 │ │ │ │ │ │ │ │ │ │ │ ← 这是底部
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘在代码中,我们用一个二维数组来表示这个网格:
// 10列 x 20行的游戏网格
// 0 表示空,非0表示被某种颜色的方块占据
const int Columns = 10;
const int Rows = 20;
// 创建并初始化网格
int[,] grid = new int[Columns, Rows];
// 把所有格子初始化为0(空)
for (int x = 0; x < Columns; x++)
{
for (int y = 0; y < Rows; y++)
{
grid[x, y] = 0;
}
}
// 检查某个位置是否为空
bool IsCellEmpty(int x, int y)
{
// 如果坐标超出范围,认为有东西(当作"墙")
if (x < 0 || x >= Columns || y < 0 || y >= Rows)
return false;
return grid[x, y] == 0;
}
// 把一个格子标记为"被占据"
void SetCell(int x, int y, int colorIndex)
{
if (x >= 0 && x < Columns && y >= 0 && y < Rows)
{
grid[x, y] = colorIndex;
}
}# 10列 x 20行的游戏网格
# 0 表示空,非0表示被某种颜色的方块占据
const Columns = 10
const Rows = 20
# 创建并初始化网格
var grid: Array = []
func _ready():
# 初始化一个10x20的空网格
grid = []
for x in range(Columns):
var column = []
for y in range(Rows):
column.append(0)
grid.append(column)
# 检查某个位置是否为空
func is_cell_empty(x: int, y: int) -> bool:
# 如果坐标超出范围,认为有东西(当作"墙")
if x < 0 or x >= Columns or y < 0 or y >= Rows:
return false
return grid[x][y] == 0
# 把一个格子标记为"被占据"
func set_cell(x: int, y: int, color_index: int) -> void:
if x >= 0 and x < Columns and y >= 0 and y < Rows:
grid[x][y] = color_index坐标系说明
在Godot中,坐标系的原点(0,0)通常在左上角:
- x轴:从左到右递增(0 → 9)
- y轴:从上到下递增(0 → 19)
所以行号0是最顶部,行号19是最底部。方块从y=0的位置出现,向下(y增大的方向)掉落。
1.4 七种方块
俄罗斯方块一共只有七种形状的方块,每种由4个小方格组成(所以又叫"四格骨牌")。它们各有名字,通常用字母I、O、T、S、Z、J、L来称呼:
| 方块 | 形状 | 名称 | 颜色(经典) |
|---|---|---|---|
| I | ■■■■ | 长条 | 青色 |
| O | ■■ ■■ | 正方形 | 黄色 |
| T | ■■■ ■ | T形 | 紫色 |
| S | ■■ ■■ | S形 | 绿色 |
| Z | ■■ ■■ | Z形 | 红色 |
| J | ■ ■■■ | J形 | 蓝色 |
| L | ■ ■■■ | L形 | 橙色 |
方块的数据结构
每种方块可以用一个矩阵来表示。比如T形方块可以表示为一个3x3的矩阵:
T形方块的初始状态(矩阵表示):
0 1 0
1 1 1
0 0 0
其中 1 表示有方格,0 表示空在代码中,我们用一个二维数组来存储每种方块的形状:
/// <summary>
/// 方块类型枚举——七种方块各有一个名字
/// </summary>
public enum PieceType
{
I, O, T, S, Z, J, L
}
/// <summary>
/// 每种方块的旋转状态数据
/// 外层数组:4种旋转状态(0°, 90°, 180°, 270°)
/// 内层数组:方块的形状矩阵
/// </summary>
public static class PieceData
{
// I形方块(4x4矩阵)
public static readonly int[,,] I = new int[,,]
{
// 状态0
{ { 0, 0, 0, 0 },
{ 1, 1, 1, 1 },
{ 0, 0, 0, 0 },
{ 0, 0, 0, 0 } },
// 状态1
{ { 0, 0, 1, 0 },
{ 0, 0, 1, 0 },
{ 0, 0, 1, 0 },
{ 0, 0, 1, 0 } },
// 状态2
{ { 0, 0, 0, 0 },
{ 0, 0, 0, 0 },
{ 1, 1, 1, 1 },
{ 0, 0, 0, 0 } },
// 状态3
{ { 0, 1, 0, 0 },
{ 0, 1, 0, 0 },
{ 0, 1, 0, 0 },
{ 0, 1, 0, 0 } }
};
// T形方块(3x3矩阵)
public static readonly int[,,] T = new int[,,]
{
{ { 0, 1, 0 },
{ 1, 1, 1 },
{ 0, 0, 0 } },
{ { 0, 1, 0 },
{ 0, 1, 1 },
{ 0, 1, 0 } },
{ { 0, 0, 0 },
{ 1, 1, 1 },
{ 0, 1, 0 } },
{ { 0, 1, 0 },
{ 1, 1, 0 },
{ 0, 1, 0 } }
};
// O形方块(2x2矩阵,所有状态都一样)
public static readonly int[,,] O = new int[,,]
{
{ { 1, 1 },
{ 1, 1 } },
{ { 1, 1 },
{ 1, 1 } },
{ { 1, 1 },
{ 1, 1 } },
{ { 1, 1 },
{ 1, 1 } }
};
}## 方块类型枚举——七种方块各有一个名字
enum PieceType { I, O, T, S, Z, J, L }
## 每种方块的旋转状态数据
## 外层数组:4种旋转状态(0°, 90°, 180°, 270°)
## 内层数组:方块的形状矩阵
class_name PieceData
# I形方块(4x4矩阵)
const I: Array = [
# 状态0
[
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0]
],
# 状态1
[
[0, 0, 1, 0],
[0, 0, 1, 0],
[0, 0, 1, 0],
[0, 0, 1, 0]
],
# 状态2
[
[0, 0, 0, 0],
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0]
],
# 状态3
[
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0]
]
]
# T形方块(3x3矩阵)
const T: Array = [
[
[0, 1, 0],
[1, 1, 1],
[0, 0, 0]
],
[
[0, 1, 0],
[0, 1, 1],
[0, 1, 0]
],
[
[0, 0, 0],
[1, 1, 1],
[0, 1, 0]
],
[
[0, 1, 0],
[1, 1, 0],
[0, 1, 0]
]
]
# O形方块(2x2矩阵,所有状态都一样)
const O: Array = [
[[1, 1], [1, 1]],
[[1, 1], [1, 1]],
[[1, 1], [1, 1]],
[[1, 1], [1, 1]]
]1.5 消行机制
当一整行被方块填满时,这一行就会被消除,上面的所有行会往下移动一格。这是俄罗斯方块最核心的得分机制。
消行检测的逻辑
用大白话说就是:从下往上检查每一行,看看有没有哪一行全都是1(没有0)。
/// <summary>
/// 检查并消除所有满行
/// 返回消除了多少行
/// </summary>
int ClearLines()
{
int linesCleared = 0;
// 从最底部开始往上检查
for (int y = Rows - 1; y >= 0; y--)
{
// 检查这一行是不是满了
bool isFull = true;
for (int x = 0; x < Columns; x++)
{
if (grid[x, y] == 0)
{
isFull = false;
break; // 只要有一个空的,这行就不满
}
}
// 如果这行满了,就消除它
if (isFull)
{
linesCleared++;
// 把这行上面的所有行都往下移一格
for (int aboveY = y; aboveY > 0; aboveY--)
{
for (int x = 0; x < Columns; x++)
{
grid[x, aboveY] = grid[x, aboveY - 1];
}
}
// 最顶行清空
for (int x = 0; x < Columns; x++)
{
grid[x, 0] = 0;
}
// 因为我们把上面的行都移下来了
// 所以要重新检查当前这一行
y++;
}
}
return linesCleared;
}## 检查并消除所有满行
## 返回消除了多少行
func clear_lines() -> int:
var lines_cleared: int = 0
# 从最底部开始往上检查
var y: int = Rows - 1
while y >= 0:
# 检查这一行是不是满了
var is_full: bool = true
for x in range(Columns):
if grid[x][y] == 0:
is_full = false
break # 只要有一个空的,这行就不满
# 如果这行满了,就消除它
if is_full:
lines_cleared += 1
# 把这行上面的所有行都往下移一格
var above_y: int = y
while above_y > 0:
for x in range(Columns):
grid[x][above_y] = grid[x][above_y - 1]
above_y -= 1
# 最顶行清空
for x in range(Columns):
grid[x][0] = 0
# 因为我们把上面的行都移下来了
# 所以要重新检查当前这一行
y += 1
y -= 1
return lines_cleared同时消多行
一次性消除多行会获得更高的分数奖励:
| 消除行数 | 术语 | 得分倍率 |
|---|---|---|
| 1行 | Single(单消) | 1x |
| 2行 | Double(双消) | 3x |
| 3行 | Triple(三消) | 5x |
| 4行 | Tetris(四消) | 8x |
1.6 游戏结束条件
游戏结束的判定很简单:当一个新方块生成时,它的位置上已经有方块了。
这意味着积木已经堆到了最顶部,玩家再也无法放置新方块了。
/// <summary>
/// 检查游戏是否结束
/// 新方块生成时调用,如果方块和已有方块重叠,则游戏结束
/// </summary>
bool CheckGameOver(int[,] pieceShape, int startX, int startY)
{
for (int row = 0; row < pieceShape.GetLength(0); row++)
{
for (int col = 0; col < pieceShape.GetLength(1); col++)
{
// 只检查方块实际占据的格子(值为1的格子)
if (pieceShape[row, col] != 0)
{
int gridX = startX + col;
int gridY = startY + row;
// 如果这个格子在网格范围内,但已经被占据
if (gridX >= 0 && gridX < Columns
&& gridY >= 0 && gridY < Rows
&& grid[gridX, gridY] != 0)
{
return true; // 游戏结束!
}
}
}
}
return false; // 游戏继续
}## 检查游戏是否结束
## 新方块生成时调用,如果方块和已有方块重叠,则游戏结束
func check_game_over(piece_shape: Array, start_x: int, start_y: int) -> bool:
for row in range(piece_shape.size()):
for col in range(piece_shape[row].size()):
# 只检查方块实际占据的格子(值为1的格子)
if piece_shape[row][col] != 0:
var grid_x: int = start_x + col
var grid_y: int = start_y + row
# 如果这个格子在网格范围内,但已经被占据
if grid_x >= 0 and grid_x < Columns \
and grid_y >= 0 and grid_y < Rows \
and grid[grid_x][grid_y] != 0:
return true # 游戏结束!
return false # 游戏继续1.7 输入控制
玩家通过键盘(或触屏)来控制当前正在下落的方块。以下是标准控制方案:
| 按键 | 功能 |
|---|---|
| ← → | 左右移动方块 |
| ↑ 或 X | 顺时针旋转方块 |
| Z | 逆时针旋转方块 |
| ↓ | 加速下落(软降) |
| 空格 | 直接落到底部(硬降) |
| C 或 Shift | 暂存方块(Hold) |
| P 或 Esc | 暂停游戏 |
public override void _UnhandledInput(InputEvent @event)
{
if (_gameState != GameState.Playing) return;
// 左右移动
if (@event.IsActionPressed("move_left"))
TryMove(-1, 0);
else if (@event.IsActionPressed("move_right"))
TryMove(1, 0);
// 旋转
if (@event.IsActionPressed("rotate_cw"))
TryRotate(1); // 顺时针
else if (@event.IsActionPressed("rotate_ccw"))
TryRotate(-1); // 逆时针
// 软降(加速下落)
if (@event.IsActionPressed("soft_drop"))
TryMove(0, 1);
// 硬降(直接落到底)
if (@event.IsActionPressed("hard_drop"))
HardDrop();
// 暂存
if (@event.IsActionPressed("hold"))
HoldPiece();
// 暂停
if (@event.IsActionPressed("pause"))
TogglePause();
}func _unhandled_input(event: InputEvent) -> void:
if _game_state != GameState.PLAYING:
return
# 左右移动
if event.is_action_pressed("move_left"):
try_move(-1, 0)
elif event.is_action_pressed("move_right"):
try_move(1, 0)
# 旋转
if event.is_action_pressed("rotate_cw"):
try_rotate(1) # 顺时针
elif event.is_action_pressed("rotate_ccw"):
try_rotate(-1) # 逆时针
# 软降(加速下落)
if event.is_action_pressed("soft_drop"):
try_move(0, 1)
# 硬降(直接落到底)
if event.is_action_pressed("hard_drop"):
hard_drop()
# 暂存
if event.is_action_pressed("hold"):
hold_piece()
# 暂停
if event.is_action_pressed("pause"):
toggle_pause()1.8 计分系统
俄罗斯方块的计分有几种常见的规则,我们采用现代标准(Guideline)的计分方式:
| 动作 | 基础分 | 说明 |
|---|---|---|
| Single(消1行) | 100 x 等级 | 消除1行 |
| Double(消2行) | 300 x 等级 | 同时消除2行 |
| Triple(消3行) | 500 x 等级 | 同时消除3行 |
| Tetris(消4行) | 800 x 等级 | 同时消除4行 |
| T-Spin Single | 800 x 等级 | T-Spin消1行 |
| T-Spin Double | 1200 x 等级 | T-Spin消2行 |
| T-Spin Triple | 1600 x 等级 | T-Spin消3行 |
| 软降(每格) | 1 | 加速下落经过的每一格 |
| 硬降(每格) | 2 | 直接落地经过的每一格 |
/// <summary>
/// 计分规则
/// </summary>
public static class ScoringRules
{
// 消行基础分(乘以当前等级)
public const int SingleScore = 100;
public const int DoubleScore = 300;
public const int TripleScore = 500;
public const int TetrisScore = 800;
// T-Spin额外加分
public const int TSpinSingleBonus = 800;
public const int TSpinDoubleBonus = 1200;
public const int TSpinTripleBonus = 1600;
// 软降/硬降每格分数
public const int SoftDropPerCell = 1;
public const int HardDropPerCell = 2;
/// <summary>
/// 根据消除行数计算得分
/// </summary>
public static int CalculateScore(int linesCleared, int level, bool isTSpin = false)
{
int baseScore = linesCleared switch
{
1 => isTSpin ? TSpinSingleBonus : SingleScore,
2 => isTSpin ? TSpinDoubleBonus : DoubleScore,
3 => isTSpin ? TSpinTripleBonus : TripleScore,
4 => TetrisScore,
_ => 0
};
return baseScore * level;
}
}## 计分规则
class_name ScoringRules
# 消行基础分(乘以当前等级)
const SINGLE_SCORE: int = 100
const DOUBLE_SCORE: int = 300
const TRIPLE_SCORE: int = 500
const TETRIS_SCORE: int = 800
# T-Spin额外加分
const TSPIN_SINGLE_BONUS: int = 800
const TSPIN_DOUBLE_BONUS: int = 1200
const TSPIN_TRIPLE_BONUS: int = 1600
# 软降/硬降每格分数
const SOFT_DROP_PER_CELL: int = 1
const HARD_DROP_PER_CELL: int = 2
## 根据消除行数计算得分
static func calculate_score(lines_cleared: int, level: int, is_tspin: bool = false) -> int:
var base_score: int
match lines_cleared:
1:
base_score = TSPIN_SINGLE_BONUS if is_tspin else SINGLE_SCORE
2:
base_score = TSPIN_DOUBLE_BONUS if is_tspin else DOUBLE_SCORE
3:
base_score = TSPIN_TRIPLE_BONUS if is_tspin else TRIPLE_SCORE
4:
base_score = TETRIS_SCORE
_:
base_score = 0
return base_score * level1.9 游戏状态机
一个完整的游戏需要管理不同的状态。我们用一个简单的状态机来管理:
┌──────────┐ 开始 ┌──────────┐
│ 主菜单 │ ────────→ │ 游戏中 │
└──────────┘ └────┬─────┘
↑ │
│ 游戏结束 │
│ ←────────────── │
│ │
│ 暂停 │
│ ┌────────┐ │
└──── │ 暂停 │ ←──────┘
└────────┘/// <summary>
/// 游戏状态枚举
/// </summary>
public enum GameState
{
Menu, // 主菜单
Playing, // 游戏进行中
Paused, // 暂停
GameOver // 游戏结束
}
public partial class GameManager : Node
{
private GameState _currentState = GameState.Menu;
public GameState CurrentState
{
get => _currentState;
set
{
GameState oldState = _currentState;
_currentState = value;
OnStateChanged(oldState, _currentState);
}
}
/// <summary>
/// 状态切换时的处理
/// </summary>
private void OnStateChanged(GameState oldState, GameState newState)
{
// 离开旧状态时做的事
switch (oldState)
{
case GameState.Playing:
// 暂停游戏计时
GetTree().Paused = true;
break;
}
// 进入新状态时做的事
switch (newState)
{
case GameState.Playing:
GetTree().Paused = false;
break;
case GameState.Paused:
// 显示暂停菜单
GetNode<CanvasLayer>("PauseMenu").Visible = true;
break;
case GameState.GameOver:
// 显示游戏结束画面
GetNode<CanvasLayer>("GameOverScreen").Visible = true;
break;
}
}
}## 游戏状态枚举
enum GameState { MENU, PLAYING, PAUSED, GAME_OVER }
extends Node
var _current_state: GameState = GameState.MENU
func _ready() -> void:
pass
## 状态切换时的处理
func set_state(new_state: GameState) -> void:
var old_state: GameState = _current_state
_current_state = new_state
_on_state_changed(old_state, new_state)
func _on_state_changed(old_state: GameState, new_state: GameState) -> void:
# 离开旧状态时做的事
match old_state:
GameState.PLAYING:
get_tree().paused = true
# 进入新状态时做的事
match new_state:
GameState.PLAYING:
get_tree().paused = false
GameState.PAUSED:
# 显示暂停菜单
$PauseMenu.visible = true
GameState.GAME_OVER:
# 显示游戏结束画面
$GameOverScreen.visible = true1.10 本章小结
在这一章中,我们从零开始分析了俄罗斯方块的核心玩法,涵盖了以下内容:
| 知识点 | 说明 |
|---|---|
| 核心循环 | 生成→操控→落定→消行→得分的循环 |
| 网格系统 | 10x20的二维数组,每个格子要么空要么被占据 |
| 七种方块 | I/O/T/S/Z/J/L,每种用矩阵表示 |
| 消行机制 | 检测满行、消除、上方行下移 |
| 游戏结束 | 新方块与已有方块重叠 |
| 输入控制 | 键盘映射到移动、旋转、下落等操作 |
| 计分系统 | 消行得分乘以等级,软降/硬降额外加分 |
| 状态机 | 管理菜单、游戏中、暂停、结束四种状态 |
在下一章中,我们将正式搭建项目结构,创建Godot场景,把这些概念变成真正可运行的代码。
