2. 项目搭建
开心消消乐——项目搭建
本章要做什么?
上一章我们设计了三消游戏的核心玩法,现在要把设计变成实际的 Godot 项目。这一章就像盖房子打地基——地基打得稳,后面盖楼才不会歪。
我们本章要完成的事情:
- 在 Godot 中创建新项目
- 搭建场景树结构
- 编写 GameManager 脚本
- 准备方块的视觉素材(用彩色方块代替图片即可)
创建 Godot 项目
打开 Godot 4.x 编辑器,点击"新建项目":
| 设置项 | 值 | 说明 |
|---|---|---|
| 项目名称 | MatchThree | 消消乐的英文名 |
| 项目路径 | 你喜欢的任意位置 | 建议放在一个专门的游戏开发文件夹里 |
| 渲染器 | Compatibility(兼容模式) | 2D游戏选这个,性能更好 |
| 版本控制 | 勾选 Git | 养成好习惯,每个项目都开启版本控制 |
点击"创建并编辑",Godot 就会为你生成一个空项目。
项目目录结构
创建好项目后,在文件系统面板中创建以下文件夹结构:
MatchThree/
├── scenes/ # 存放所有场景文件
│ ├── Main.tscn # 主场景
│ ├── GameBoard.tscn # 棋盘场景
│ └── Piece.tscn # 单个方块场景
├── scripts/ # 存放所有脚本
│ ├── GameManager.cs # 游戏管理器
│ ├── GameBoard.cs # 棋盘逻辑
│ └── Piece.cs # 方块逻辑
├── resources/ # 存放资源文件
│ └── Theme.tres # 游戏主题(字体、颜色等)
└── assets/ # 存放美术素材
└── pieces/ # 方块图片为什么要把场景和脚本分开放?
想象你有一个工具箱(scenes)和一个说明书(scripts)。工具箱里放着锤子、螺丝刀等工具(场景),说明书里写着每个工具怎么用(脚本)。分开放的好处是:找工具的时候不会被说明书干扰,看说明书的时候不会被工具绊倒。
在 Godot 中,把 .tscn 场景文件和 .cs/.gd 脚本文件分文件夹管理,是推荐的项目组织方式。
创建主场景
场景节点树
主场景是整个游戏的"入口"。它的节点树结构如下:
Main (Control) ← 根节点,管理整个界面布局
├── Background (ColorRect) ← 背景色块
├── GameBoard (Control) ← 棋盘区域
│ └── (方块会在这里动态生成)
├── HUD (Control) ← 顶部信息栏
│ ├── ScoreLabel (Label) ← 分数显示
│ ├── MovesLabel (Label) ← 剩余步数显示
│ └── TargetLabel (Label) ← 目标分数显示
└── GameManager (Node) ← 游戏管理器(脚本节点)创建步骤
- 在 Godot 中点击"场景 → 新建场景",选择 Control 作为根节点,命名为
Main - 添加一个 ColorRect 子节点,命名为
Background - 添加一个 Control 子节点,命名为
GameBoard - 添加一个 Control 子节点,命名为
HUD - 在
HUD下添加三个 Label 子节点:ScoreLabel、MovesLabel、TargetLabel - 添加一个 Node 子节点,命名为
GameManager
设置主场景布局
在 Main 根节点的检查器中,将 Layout 设置为 Full Rect(全屏布局)。同样把 Background 也设为 Full Rect。
设置 Background 的 Color 为一个温暖的颜色,比如 #2D1B69(深紫色背景)。
using Godot;
/// <summary>
/// 主场景控制器
/// 负责初始化游戏和连接各个子系统
/// </summary>
public partial class Main : Control
{
/// <summary>游戏管理器节点的路径</summary>
[Export] public GameManager GameManager { get; set; }
/// <summary>棋盘节点</summary>
[Export] public Control GameBoard { get; set; }
/// <summary>分数标签</summary>
[Export] public Label ScoreLabel { get; set; }
/// <summary>步数标签</summary>
[Export] public Label MovesLabel { get; set; }
/// <summary>目标分数标签</summary>
[Export] public Label TargetLabel { get; set; }
public override void _Ready()
{
// 启动游戏
StartGame();
}
/// <summary>
/// 开始新游戏
/// </summary>
public void StartGame()
{
GD.Print("游戏开始!");
UpdateHUD(0, 30, 1000);
}
/// <summary>
/// 更新顶部信息栏
/// </summary>
/// <param name="score">当前分数</param>
/// <param name="moves">剩余步数</param>
/// <param name="target">目标分数</param>
public void UpdateHUD(int score, int moves, int target)
{
ScoreLabel.Text = $"分数: {score}";
MovesLabel.Text = $"步数: {moves}";
TargetLabel.Text = $"目标: {target}";
}
}extends Control
## 游戏管理器节点
@export var game_manager: GameManager
## 棋盘节点
@export var game_board: Control
## 分数标签
@export var score_label: Label
## 步数标签
@export var moves_label: Label
## 目标分数标签
@export var target_label: Label
func _ready() -> void:
# 启动游戏
start_game()
## 开始新游戏
func start_game() -> void:
print("游戏开始!")
update_hud(0, 30, 1000)
## 更新顶部信息栏
## score: 当前分数, moves: 剩余步数, target: 目标分数
func update_hud(score: int, moves: int, target: int) -> void:
score_label.text = "分数: %d" % score
moves_label.text = "步数: %d" % moves
target_label.text = "目标: %d" % targetGameManager 游戏管理器
GameManager 是整个游戏的"大脑"。它不直接管理方块的移动和消除,而是负责协调各个子系统——就像一个乐队指挥,不自己演奏乐器,但告诉每个人什么时候该演奏。
GameManager 的职责
| 职责 | 说明 |
|---|---|
| 管理游戏状态 | 控制游戏在不同阶段之间切换 |
| 记录分数和步数 | 跟踪玩家的进度 |
| 判断胜负 | 检查是否达到目标或用完步数 |
| 协调子系统 | 通知棋盘进行匹配检测、通知UI更新 |
GameManager 完整脚本
using Godot;
using System;
/// <summary>
/// 游戏状态枚举
/// </summary>
public enum GameState
{
Idle, // 等待玩家操作
Swapping, // 交换动画中
Checking, // 检测匹配
Removing, // 消除动画中
Falling, // 下落动画中
Paused // 暂停
}
/// <summary>
/// 方块类型枚举
/// </summary>
public enum PieceType
{
Red,
Blue,
Green,
Yellow,
Purple,
Empty
}
/// <summary>
/// 游戏管理器 —— 整个游戏的"大脑"
/// 负责管理游戏状态、分数、步数,以及协调各子系统
/// </summary>
public partial class GameManager : Node
{
// ===== 常量 =====
/// <summary>棋盘的行数</summary>
public const int ROWS = 8;
/// <summary>棋盘的列数</summary>
public const int COLS = 8;
/// <summary>方块种类数量(不含空位)</summary>
public const int PIECE_TYPES = 5;
/// <summary>每个方块的像素大小</summary>
public const int PIECE_SIZE = 64;
/// <summary>棋盘的总像素宽度</summary>
public const int BOARD_WIDTH = COLS * PIECE_SIZE;
/// <summary>棋盘的总像素高度</summary>
public const int BOARD_HEIGHT = ROWS * PIECE_SIZE;
// ===== 游戏数据 =====
/// <summary>当前分数</summary>
private int _score = 0;
/// <summary>剩余步数</summary>
private int _movesLeft = 30;
/// <summary>目标分数</summary>
private int _targetScore = 1000;
/// <summary>当前连击数</summary>
private int _comboCount = 0;
// ===== 状态管理 =====
private GameState _currentState = GameState.Idle;
/// <summary>当前游戏状态</summary>
public GameState CurrentState
{
get => _currentState;
set
{
_currentState = value;
GD.Print($"[GameManager] 状态: {_currentState}");
}
}
// ===== 信号 =====
/// <summary>分数变化时发出</summary>
[Signal] public delegate void ScoreChangedEventHandler(int newScore);
/// <summary>步数变化时发出</summary>
[Signal] public delegate void MovesChangedEventHandler(int movesLeft);
/// <summary>游戏胜利时发出</summary>
[Signal] public delegate void GameWonEventHandler();
/// <summary>游戏失败时发出</summary>
[Signal] public delegate void GameLostEventHandler();
public override void _Ready()
{
GD.Print("[GameManager] 初始化完成");
GD.Print($"[GameManager] 棋盘大小: {ROWS}x{COLS}");
GD.Print($"[GameManager] 方块种类: {PIECE_TYPES}");
GD.Print($"[GameManager] 方块大小: {PIECE_SIZE}px");
}
/// <summary>
/// 增加分数
/// </summary>
/// <param name="amount">要增加的分数</param>
public void AddScore(int amount)
{
_score += amount;
EmitSignal(SignalName.ScoreChanged, _score);
GD.Print($"[GameManager] 分数: {_score} (+{amount})");
// 检查是否达到目标
if (_score >= _targetScore)
{
GD.Print("[GameManager] 恭喜!达到目标分数!");
EmitSignal(SignalName.GameWon);
}
}
/// <summary>
/// 消耗一步
/// </summary>
public void UseMove()
{
_movesLeft--;
EmitSignal(SignalName.MovesChanged, _movesLeft);
GD.Print($"[GameManager] 剩余步数: {_movesLeft}");
if (_movesLeft <= 0)
{
GD.Print("[GameManager] 步数用完了!");
EmitSignal(SignalName.GameLost);
}
}
/// <summary>
/// 增加连击数
/// </summary>
public void IncrementCombo()
{
_comboCount++;
GD.Print($"[GameManager] 连击 x{_comboCount}!");
}
/// <summary>
/// 重置连击数
/// </summary>
public void ResetCombo()
{
_comboCount = 0;
}
/// <summary>
/// 获取当前连击数
/// </summary>
public int GetCombo() => _comboCount;
/// <summary>
/// 获取当前分数
/// </summary>
public int GetScore() => _score;
/// <summary>
/// 获取剩余步数
/// </summary>
public int GetMovesLeft() => _movesLeft;
}extends Node
## ===== 常量 =====
## 棋盘的行数
const ROWS: int = 8
## 棋盘的列数
const COLS: int = 8
## 方块种类数量(不含空位)
const PIECE_TYPES: int = 5
## 每个方块的像素大小
const PIECE_SIZE: int = 64
## 棋盘的总像素宽度
const BOARD_WIDTH: int = COLS * PIECE_SIZE
## 棋盘的总像素高度
const BOARD_HEIGHT: int = ROWS * PIECE_SIZE
## ===== 游戏数据 =====
## 当前分数
var _score: int = 0
## 剩余步数
var _moves_left: int = 30
## 目标分数
var _target_score: int = 1000
## 当前连击数
var _combo_count: int = 0
## ===== 状态管理 =====
## 游戏状态枚举
enum GameState {
IDLE, ## 等待玩家操作
SWAPPING, ## 交换动画中
CHECKING, ## 检测匹配
REMOVING, ## 消除动画中
FALLING, ## 下落动画中
PAUSED ## 暂停
}
## 当前游戏状态
var current_state: int = GameState.IDLE:
set(value):
current_state = value
print("[GameManager] 状态: %s" % current_state)
## ===== 信号 =====
## 分数变化时发出
signal score_changed(new_score: int)
## 步数变化时发出
signal moves_changed(moves_left: int)
## 游戏胜利时发出
signal game_won()
## 游戏失败时发出
signal game_lost()
func _ready() -> void:
print("[GameManager] 初始化完成")
print("[GameManager] 棋盘大小: %dx%d" % [ROWS, COLS])
print("[GameManager] 方块种类: %d" % PIECE_TYPES)
print("[GameManager] 方块大小: %dpx" % PIECE_SIZE)
## 增加分数
## amount: 要增加的分数
func add_score(amount: int) -> void:
_score += amount
score_changed.emit(_score)
print("[GameManager] 分数: %d (+%d)" % [_score, amount])
# 检查是否达到目标
if _score >= _target_score:
print("[GameManager] 恭喜!达到目标分数!")
game_won.emit()
## 消耗一步
func use_move() -> void:
_moves_left -= 1
moves_changed.emit(_moves_left)
print("[GameManager] 剩余步数: %d" % _moves_left)
if _moves_left <= 0:
print("[GameManager] 步数用完了!")
game_lost.emit()
## 增加连击数
func increment_combo() -> void:
_combo_count += 1
print("[GameManager] 连击 x%d!" % _combo_count)
## 重置连击数
func reset_combo() -> void:
_combo_count = 0
## 获取当前连击数
func get_combo() -> int:
return _combo_count
## 获取当前分数
func get_score() -> int:
return _score
## 获取剩余步数
func get_moves_left() -> int:
return _moves_left创建方块场景
每个方块是一个独立的小场景,这样方便统一管理方块的样式和行为。
方块场景节点树
Piece (Area2D) ← 方块根节点
├── Sprite2D ← 方块的视觉外观(一个彩色方块)
├── CollisionShape2D ← 碰撞区域(用于点击检测)
└── SelectedIndicator (Sprite2D) ← 选中时的边框指示器(默认隐藏)创建步骤
- 新建场景,根节点选择 Area2D,命名为
Piece - 添加 Sprite2D 子节点
- 添加 CollisionShape2D 子节点
- 在
CollisionShape2D的 Shape 属性中,新建一个 RectangleShape2D - 将 RectangleShape2D 的 Size 设为
(64, 64) - 添加一个 Sprite2D 子节点,命名为
SelectedIndicator,默认设为不可见(Visible = false)
方块脚本
using Godot;
/// <summary>
/// 单个方块
/// 棋盘上的每一个彩色方块都是一个 Piece 实例
/// </summary>
public partial class Piece : Area2D
{
// ===== 方块颜色映射 =====
// 每种方块类型对应一个颜色
private static readonly Color[] PieceColors = new Color[]
{
new Color("FF4444"), // 红色
new Color("4488FF"), // 蓝色
new Color("44FF44"), // 绿色
new Color("FFDD44"), // 黄色
new Color("CC44FF"), // 紫色
};
/// <summary>方块精灵</summary>
private Sprite2D _sprite;
/// <summary>选中指示器</summary>
private Sprite2D _selectedIndicator;
/// <summary>方块在棋盘上的行</summary>
public int Row { get; set; }
/// <summary>方块在棋盘上的列</summary>
public int Col { get; set; }
/// <summary>方块的类型(颜色)</summary>
public PieceType Type { get; private set; }
/// <summary>是否被选中</summary>
public bool IsSelected { get; private set; }
// ===== 信号 =====
/// <summary>方块被点击时发出</summary>
[Signal] public delegate void PieceClickedEventHandler(Piece piece);
public override void _Ready()
{
_sprite = GetNode<Sprite2D>("Sprite2D");
_selectedIndicator = GetNode<Sprite2D>("SelectedIndicator");
// 连接点击信号
InputEvent += OnInputEvent;
GD.Print($"[Piece] 方块创建完成,位置: ({Row}, {Col})");
}
/// <summary>
/// 初始化方块
/// </summary>
/// <param name="type">方块类型</param>
/// <param name="row">行号</param>
/// <param name="col">列号</param>
public void Initialize(PieceType type, int row, int col)
{
Type = type;
Row = row;
Col = col;
// 根据类型设置颜色
int colorIndex = (int)type;
if (colorIndex >= 0 && colorIndex < PieceColors.Length)
{
_sprite.Modulate = PieceColors[colorIndex];
}
}
/// <summary>
/// 处理输入事件
/// </summary>
private void OnInputEvent(Viewport viewport, InputEvent inputEvent, int shapeIdx)
{
if (inputEvent is InputEventMouseButton mouseEvent
&& mouseEvent.Pressed
&& mouseEvent.ButtonIndex == MouseButton.Left)
{
ToggleSelection();
EmitSignal(SignalName.PieceClicked, this);
}
}
/// <summary>
/// 切换选中状态
/// </summary>
public void ToggleSelection()
{
IsSelected = !IsSelected;
_selectedIndicator.Visible = IsSelected;
}
/// <summary>
/// 取消选中
/// </summary>
public void Deselect()
{
IsSelected = false;
_selectedIndicator.Visible = false;
}
/// <summary>
/// 设置方块的屏幕位置(根据行列号计算像素坐标)
/// </summary>
public void SetGridPosition(int row, int col, int pieceSize)
{
Row = row;
Col = col;
Position = new Vector2(col * pieceSize + pieceSize / 2,
row * pieceSize + pieceSize / 2);
}
}extends Area2D
## ===== 方块颜色映射 =====
## 每种方块类型对应一个颜色
var PIECE_COLORS: Array[Color] = [
Color("FF4444"), # 红色
Color("4488FF"), # 蓝色
Color("44FF44"), # 绿色
Color("FFDD44"), # 黄色
Color("CC44FF"), # 紫色
]
## 方块精灵
@onready var _sprite: Sprite2D = $Sprite2D
## 选中指示器
@onready var _selected_indicator: Sprite2D = $SelectedIndicator
## 方块在棋盘上的行
var row: int = 0
## 方块在棋盘上的列
var col: int = 0
## 方块的类型(颜色)
var type: int = 0 # PieceType 枚举值
## 是否被选中
var is_selected: bool = false
## ===== 信号 =====
## 方块被点击时发出
signal piece_clicked(piece)
func _ready() -> void:
# 连接点击信号
input_event.connect(_on_input_event)
print("[Piece] 方块创建完成,位置: (%d, %d)" % [row, col])
## 初始化方块
## type: 方块类型, row: 行号, col: 列号
func initialize(piece_type: int, new_row: int, new_col: int) -> void:
type = piece_type
row = new_row
col = new_col
# 根据类型设置颜色
if type >= 0 and type < PIECE_COLORS.size():
_sprite.modulate = PIECE_COLORS[type]
## 处理输入事件
func _on_input_event(_viewport: Viewport, input_event: InputEvent, _shape_idx: int) -> void:
if input_event is InputEventMouseButton:
var mouse_event := input_event as InputEventMouseButton
if mouse_event.pressed and mouse_event.button_index == MOUSE_BUTTON_LEFT:
toggle_selection()
piece_clicked.emit(self)
## 切换选中状态
func toggle_selection() -> void:
is_selected = !is_selected
_selected_indicator.visible = is_selected
## 取消选中
func deselect() -> void:
is_selected = false
_selected_indicator.visible = false
## 设置方块的屏幕位置(根据行列号计算像素坐标)
func set_grid_position(new_row: int, new_col: int, piece_size: int) -> void:
row = new_row
col = new_col
position = Vector2(
new_col * piece_size + piece_size / 2.0,
new_row * piece_size + piece_size / 2.0
)项目设置
设置游戏窗口大小
在 Godot 顶部的菜单栏中选择 项目 → 项目设置 → 常规 → 显示 → 窗口,设置:
| 属性 | 值 | 说明 |
|---|---|---|
| Viewport Width | 512 | 8列 x 64像素 = 512 |
| Viewport Height | 640 | 8行 x 64像素 + HUD区域 |
| Stretch Mode | Canvas Items | 自动缩放适配不同屏幕 |
| Stretch Aspect | Keep | 保持画面比例不变形 |
设置主场景
在 项目 → 项目设置 → 常规 → 应用程序 → 运行 中,将 Main Scene 设为 res://scenes/Main.tscn。
设置输入映射(可选)
在 项目 → 项目设置 → 输入映射 中,可以添加自定义的输入动作。对于消消乐这种纯鼠标/触摸操作的游戏,使用默认的鼠标事件即可,不需要额外配置。
本章小结
| 完成项 | 说明 |
|---|---|
| 项目创建 | 建立了 MatchThree 项目 |
| 目录结构 | scenes/、scripts/、resources/、assets/ |
| 主场景 | Main.tscn,包含 GameBoard、HUD、GameManager |
| GameManager | 游戏管理器,管理状态、分数、步数 |
| 方块场景 | Piece.tscn,支持点击选中和颜色设置 |
| 窗口设置 | 512x640,适配移动端 |
现在我们的"地基"已经打好了。下一章,我们将在这个基础上实现棋盘系统——把8x8的方块摆到屏幕上,并确保初始状态下没有任何"意外"的三消匹配。
