2. 项目搭建
2. 保卫萝卜——项目搭建
什么是项目搭建?
上一节我们讨论了游戏的核心玩法设计。现在要把这些"想法"变成"实际能运行的东西",第一步就是搭建项目。
你可以把项目搭建理解为"准备工具箱和工作室"——在正式开始做东西之前,先把你需要的工具、材料、工作台都准备好、摆好位置。
创建 Godot 项目
打开 Godot 4.x 编辑器,点击 新建项目:
| 设置项 | 填写内容 | 说明 |
|---|---|---|
| 项目名称 | CarrotFantasy | 项目名字 |
| 项目路径 | 选一个你喜欢的位置 | 建议用英文路径,避免中文 |
| 渲染器 | Compatibility | 2D 游戏选这个更稳定 |
| 版本控制 | 勾选 Git | 方便管理代码版本 |
点击 创建并编辑 进入编辑器。
项目目录结构
好的项目结构就像整洁的房间——东西分门别类放在该放的地方,找起来方便。创建以下目录结构:
CarrotFantasy/
├── project.godot # Godot 项目配置文件(自动生成)
├── scenes/ # 场景文件(.tscn)放这里
│ ├── main.tscn # 主场景
│ ├── entities/ # 游戏实体(怪物、塔、子弹)
│ │ ├── monsters/ # 怪物场景
│ │ ├── towers/ # 防御塔场景
│ │ └── projectiles/ # 子弹场景
│ └── ui/ # 界面场景
│ ├── hud.tscn # 顶部信息栏
│ ├── tower_panel.tscn # 塔选择面板
│ └── game_over.tscn # 结算面板
├── scripts/ # 脚本文件放这里
│ ├── managers/ # 管理器脚本
│ │ ├── game_manager.gd # 游戏管理器
│ │ └── wave_manager.gd # 波次管理器
│ ├── entities/ # 实体脚本
│ │ ├── monster.gd # 怪物基类
│ │ ├── tower.gd # 塔基类
│ │ └── projectile.gd # 子弹基类
│ └── ui/ # UI 脚本
│ ├── hud.gd # HUD 脚本
│ └── tower_panel.gd # 塔选择面板脚本
├── resources/ # 资源文件
│ ├── monsters/ # 怪物数据(.tres)
│ ├── towers/ # 塔数据(.tres)
│ └── waves/ # 波次配置(.tres)
├── assets/ # 美术/音效素材
│ ├── sprites/ # 图片素材
│ │ ├── monsters/ # 怪物图片
│ │ ├── towers/ # 塔图片
│ │ └── map/ # 地图素材
│ ├── audio/ # 音效
│ │ ├── sfx/ # 音效文件
│ │ └── music/ # 背景音乐
│ └── fonts/ # 字体文件
└── autoload/ # 自动加载脚本(全局单例)在 Godot 的 文件系统 面板中,右键点击 res:// → 新建文件夹,逐一创建这些目录。
常量定义
在编写代码之前,我们先定义一些"常量"。常量就是"固定不变的值"——就像物理公式中的光速、圆周率一样,一旦确定就不会改变。
使用常量的好处:如果将来要修改某个值,只需要改一个地方,所有用到这个值的地方都会自动更新。
using Godot;
/// <summary>
/// 游戏常量 —— 游戏中的"固定规则"
/// 所有不变的数值都集中定义在这里
/// </summary>
public static class GameConstants
{
// ===== 层(Layer)定义 =====
// 层就像"分类标签",用来区分不同类型的物体
// Godot 最多支持 32 个层,我们用前几个就够了
/// <summary>怪物所在的层</summary>
public const int LayerMonster = 1;
/// <summary>防御塔所在的层</summary>
public const int LayerTower = 2;
/// <summary>子弹所在的层</summary>
public const int LayerProjectile = 3;
/// <summary>路径所在的层</summary>
public const int LayerPath = 4;
/// <summary>可建造区域所在的层</summary>
public const int LayerBuildable = 5;
/// <summary>障碍物所在的层</summary>
public const int LayerObstacle = 6;
// ===== 建造系统常量 =====
/// <summary>网格大小(每个格子多少像素)</summary>
public const int GridSize = 64;
/// <summary>地图宽度(格子数)</summary>
public const int MapWidth = 16;
/// <summary>地图高度(格子数)</summary>
public const int MapHeight = 10;
// ===== 经济系统常量 =====
/// <summary>出售返还比例(卖出塔时返还购买价的百分比)</summary>
public const float SellRefundRate = 0.6f;
/// <summary>击杀基础奖励</summary>
public const int KillBaseReward = 10;
// ===== 战斗系统常量 =====
/// <summary>默认攻击范围(像素)</summary>
public const float DefaultRange = 150.0f;
/// <summary>默认攻击间隔(秒)</summary>
public const float DefaultFireRate = 1.0f;
// ===== 怪物类型 =====
public const string MonsterNormal = "normal";
public const string MonsterFast = "fast";
public const string MonsterTank = "tank";
public const string MonsterBoss = "boss";
// ===== 塔类型 =====
public const string TowerBottle = "bottle";
public const string TowerFan = "fan";
public const string TowerRocket = "rocket";
public const string TowerRadar = "radar";
}class_name GameConstants
## 游戏常量 —— 游戏中的"固定规则"
## 所有不变的数值都集中定义在这里
# ===== 层(Layer)定义 =====
# 层就像"分类标签",用来区分不同类型的物体
# Godot 最多支持 32 个层,我们用前几个就够了
## 怪物所在的层
const LAYER_MONSTER: int = 1
## 防御塔所在的层
const LAYER_TOWER: int = 2
## 子弹所在的层
const LAYER_PROJECTILE: int = 3
## 路径所在的层
const LAYER_PATH: int = 4
## 可建造区域所在的层
const LAYER_BUILDABLE: int = 5
## 障碍物所在的层
const LAYER_OBSTACLE: int = 6
# ===== 建造系统常量 =====
## 网格大小(每个格子多少像素)
const GRID_SIZE: int = 64
## 地图宽度(格子数)
const MAP_WIDTH: int = 16
## 地图高度(格子数)
const MAP_HEIGHT: int = 10
# ===== 经济系统常量 =====
## 出售返还比例(卖出塔时返还购买价的百分比)
const SELL_REFUND_RATE: float = 0.6
## 击杀基础奖励
const KILL_BASE_REWARD: int = 10
# ===== 战斗系统常量 =====
## 默认攻击范围(像素)
const DEFAULT_RANGE: float = 150.0
## 默认攻击间隔(秒)
const DEFAULT_FIRE_RATE: float = 1.0
# ===== 怪物类型 =====
const MONSTER_NORMAL: String = "normal"
const MONSTER_FAST: String = "fast"
const MONSTER_TANK: String = "tank"
const MONSTER_BOSS: String = "boss"
# ===== 塔类型 =====
const TOWER_BOTTLE: String = "bottle"
const TOWER_FAN: String = "fan"
const TOWER_ROCKET: String = "rocket"
const TOWER_RADAR: String = "radar"创建主场景
主场景是整个游戏的"大舞台",所有其他场景都是在这个舞台上表演的。
在编辑器中创建
- 在 Godot 编辑器中点击 场景 → 新建场景
- 选择 Node2D 作为根节点,重命名为
Main - 保存为
scenes/main.tscn
添加子节点
按照以下结构,在 Main 节点下添加子节点:
| 节点类型 | 名称 | 作用 |
|---|---|---|
| Node2D | Map | 地图层,放路径和背景 |
| Node2D | Monsters | 怪物容器,运行时动态添加怪物 |
| Node2D | Towers | 防御塔容器,运行时动态添加塔 |
| Node2D | Projectiles | 子弹容器,运行时动态添加子弹 |
| Node2D | Effects | 特效容器,放爆炸粒子等 |
| CanvasLayer | UILayer | UI 层,所有界面元素放这里 |
CanvasLayer 是什么? 它就像一个"透明盖板",放在上面的东西永远在屏幕上固定位置,不会被地图的移动和缩放影响。UI 元素(如金币显示、按钮)都应该放在 CanvasLayer 下。
通过代码创建主场景
如果你更喜欢用代码来组织,也可以这样创建:
using Godot;
/// <summary>
/// 主场景脚本 —— 游戏的"大舞台"
/// 负责创建和管理所有子节点
/// </summary>
public partial class Main : Node2D
{
// 节点引用(稍后会通过编辑器绑定)
private Node2D _monstersContainer;
private Node2D _towersContainer;
private Node2D _projectilesContainer;
private Node2D _effectsContainer;
private GameManager _gameManager;
public override void _Ready()
{
// 获取节点引用
_monstersContainer = GetNode<Node2D>("Monsters");
_towersContainer = GetNode<Node2D>("Towers");
_projectilesContainer = GetNode<Node2D>("Projectiles");
_effectsContainer = GetNode<Node2D>("Effects");
_gameManager = GetNode<GameManager>("GameManager");
// 连接游戏管理器的信号
_gameManager.GameOver += OnGameOver;
_gameManager.WaveChanged += OnWaveChanged;
}
/// <summary>
/// 游戏结束回调
/// </summary>
private void OnGameOver(bool isVictory)
{
GD.Print(isVictory ? "恭喜胜利!萝卜安全了!" : "游戏失败...萝卜被偷走了!");
}
/// <summary>
/// 波次变化回调
/// </summary>
private void OnWaveChanged(int newWave)
{
GD.Print($"第 {newWave} 波怪物来袭!");
}
}extends Node2D
class_name Main
## 主场景脚本 —— 游戏的"大舞台"
## 负责创建和管理所有子节点
# 节点引用(稍后会通过编辑器绑定)
@onready var _monsters_container: Node2D = $Monsters
@onready var _towers_container: Node2D = $Towers
@onready var _projectiles_container: Node2D = $Projectiles
@onready var _effects_container: Node2D = $Effects
@onready var _game_manager: GameManager = $GameManager
func _ready() -> void:
# 连接游戏管理器的信号
_game_manager.game_over.connect(_on_game_over)
_game_manager.wave_changed.connect(_on_wave_changed)
## 游戏结束回调
func _on_game_over(is_victory: bool) -> void:
if is_victory:
print("恭喜胜利!萝卜安全了!")
else:
print("游戏失败...萝卜被偷走了!")
## 波次变化回调
func _on_wave_changed(new_wave: int) -> void:
print("第 %d 波怪物来袭!" % new_wave)配置自动加载(Autoload)
Godot 的"自动加载"(Autoload)功能可以让某个节点在游戏启动时自动创建,并且在整个游戏中随时都能访问。就像"全局变量"一样,任何脚本都能直接使用。
设置 GameManager 为自动加载
- 在 Godot 编辑器菜单中,点击 项目 → 项目设置
- 选择 Autoload 标签页
- 在 路径 栏点击浏览,选择
scripts/managers/game_manager.gd(或.cs) - 在 名称 栏填写
GameManager - 点击 添加
这样设置后,在任何脚本中都可以直接使用 GameManager 来访问游戏管理器:
// 在任何脚本中直接使用 GameManager
// 不需要 GetNode 或其他查找方式
GameManager.Instance.AddGold(50);
GameManager.Instance.LoseLife();
int currentGold = GameManager.Instance.Gold;# 在任何脚本中直接使用 GameManager
# 不需要 get_node 或其他查找方式
GameManager.add_gold(50)
GameManager.lose_life()
var current_gold: int = GameManager.gold为 GameManager 添加单例模式
为了让自动加载的 GameManager 更好用,我们需要添加一个"单例模式"——确保整个游戏中只有一个 GameManager 实例。
using Godot;
/// <summary>
/// 游戏管理器(带自动加载支持的单例版本)
/// </summary>
public partial class GameManager : Node
{
// 单例实例 —— 整个游戏只有一个
public static GameManager Instance { get; private set; }
// 信号定义(与之前相同,省略重复部分)
[Signal] public delegate void GoldChangedEventHandler(int newGold);
[Signal] public delegate void LivesChangedEventHandler(int newLives);
[Signal] public delegate void WaveChangedEventHandler(int newWave);
[Signal] public delegate void StateChangedEventHandler(GameState newState);
[Signal] public delegate void GameOverEventHandler(bool isVictory);
[ExportGroup("Game Config")]
[Export] public int StartingGold { get; set; } = 300;
[Export] public int StartingLives { get; set; } = 10;
[Export] public int TotalWaves { get; set; } = 20;
private int _gold;
private int _lives;
private int _currentWave;
private int _score;
private GameState _state;
public int Gold => _gold;
public int Lives => _lives;
public int CurrentWave => _currentWave;
public int Score => _score;
public GameState State => _state;
public override void _EnterTree()
{
// 记录单例实例
Instance = this;
}
public override void _ExitTree()
{
// 清除单例引用,防止内存泄漏
if (Instance == this)
Instance = null;
}
public override void _Ready()
{
InitGame();
}
public void InitGame()
{
_gold = StartingGold;
_lives = StartingLives;
_currentWave = 0;
_score = 0;
_state = GameState.Ready;
EmitSignal(SignalName.GoldChanged, _gold);
EmitSignal(SignalName.LivesChanged, _lives);
EmitSignal(SignalName.WaveChanged, _currentWave);
EmitSignal(SignalName.StateChanged, (int)_state);
}
public void AddGold(int amount)
{
_gold += amount;
EmitSignal(SignalName.GoldChanged, _gold);
}
public bool SpendGold(int amount)
{
if (_gold < amount) return false;
_gold -= amount;
EmitSignal(SignalName.GoldChanged, _gold);
return true;
}
public void LoseLife(int damage = 1)
{
_lives -= damage;
if (_lives < 0) _lives = 0;
EmitSignal(SignalName.LivesChanged, _lives);
if (_lives <= 0)
OnGameOver(false);
}
public void StartNextWave()
{
if (_state != GameState.Ready && _state != GameState.WaveBreak)
return;
_currentWave++;
_state = GameState.Fighting;
EmitSignal(SignalName.WaveChanged, _currentWave);
EmitSignal(SignalName.StateChanged, (int)_state);
}
public void OnWaveComplete()
{
if (_currentWave >= TotalWaves)
OnGameOver(true);
else
{
_state = GameState.WaveBreak;
EmitSignal(SignalName.StateChanged, (int)_state);
}
}
public void AddScore(int points)
{
_score += points;
}
private void OnGameOver(bool isVictory)
{
_state = isVictory ? GameState.Victory : GameState.Defeat;
EmitSignal(SignalName.StateChanged, (int)_state);
EmitSignal(SignalName.GameOver, isVictory);
}
}extends Node
class_name GameManager
## 游戏管理器(带自动加载支持的单例版本)
# 单例实例 —— 整个游戏只有一个
static var instance: GameManager
# 信号定义
signal gold_changed(new_gold: int)
signal lives_changed(new_lives: int)
signal wave_changed(new_wave: int)
signal state_changed(new_state: int)
signal game_over(is_victory: bool)
# 配置
@export var starting_gold: int = 300
@export var starting_lives: int = 10
@export var total_waves: int = 20
# 运行时状态
var _gold: int = 0
var _lives: int = 0
var _current_wave: int = 0
var _score: int = 0
var _state: int = GameState.READY
# 只读属性
var gold: int:
get: return _gold
var lives: int:
get: return _lives
var current_wave: int:
get: return _current_wave
var score: int:
get: return _score
var state: int:
get: return _state
func _enter_tree() -> void:
instance = self
func _exit_tree() -> void:
if instance == self:
instance = null
func _ready() -> void:
init_game()
func init_game() -> void:
_gold = starting_gold
_lives = starting_lives
_current_wave = 0
_score = 0
_state = GameState.READY
gold_changed.emit(_gold)
lives_changed.emit(_lives)
wave_changed.emit(_current_wave)
state_changed.emit(_state)
func add_gold(amount: int) -> void:
_gold += amount
gold_changed.emit(_gold)
func spend_gold(amount: int) -> bool:
if _gold < amount:
return false
_gold -= amount
gold_changed.emit(_gold)
return true
func lose_life(damage: int = 1) -> void:
_lives -= damage
if _lives < 0:
_lives = 0
lives_changed.emit(_lives)
if _lives <= 0:
_on_game_over(false)
func start_next_wave() -> void:
if _state != GameState.READY and _state != GameState.WAVE_BREAK:
return
_current_wave += 1
_state = GameState.FIGHTING
wave_changed.emit(_current_wave)
state_changed.emit(_state)
func on_wave_complete() -> void:
if _current_wave >= total_waves:
_on_game_over(true)
else:
_state = GameState.WAVE_BREAK
state_changed.emit(_state)
func add_score(points: int) -> void:
_score += points
func _on_game_over(is_victory: bool) -> void:
if is_victory:
_state = GameState.VICTORY
else:
_state = GameState.DEFEAT
state_changed.emit(_state)
game_over.emit(is_victory)项目设置调整
在 项目 → 项目设置 中进行以下配置:
输入映射
为游戏操作添加输入按键:
| 操作名称 | 按键 | 用途 |
|---|---|---|
ui_select | 鼠标左键 | 选择/放置塔 |
ui_cancel | 鼠标右键 / Escape | 取消选择 |
ui_upgrade | U | 升级选中的塔 |
ui_sell | S | 出售选中的塔 |
图层设置
在 项目设置 → 层名称 中设置碰撞层:
| 层编号 | 名称 | 说明 |
|---|---|---|
| 1 | Monster | 怪物 |
| 2 | Tower | 防御塔 |
| 3 | Projectile | 子弹 |
| 4 | Path | 路径 |
| 5 | Buildable | 可建造区域 |
| 6 | Obstacle | 障碍物 |
窗口设置
在 项目设置 → 显示 → 窗口 中:
| 设置项 | 值 | 说明 |
|---|---|---|
| 视口宽度 | 1024 | 游戏画面宽度 |
| 视口高度 | 640 | 游戏画面高度 |
| 拉伸模式 | Canvas Items | 自动缩放适配屏幕 |
| 拉伸方面 | Keep | 保持宽高比 |
项目入口设置
在 项目 → 项目设置 → 常规 → Application → Run 中,将 主场景 设置为 scenes/main.tscn。这样每次点击运行按钮时,Godot 就会自动加载这个场景。
本节小结
| 完成事项 | 说明 |
|---|---|
| 创建项目 | 新建 Godot 4.x 项目,使用 Compatibility 渲染器 |
| 目录结构 | scenes/ scripts/ resources/ assets/ 四大目录 |
| 常量定义 | GameConstants 集中管理所有固定数值 |
| 主场景 | Main 节点下挂载 Map、Monsters、Towers 等容器 |
| 自动加载 | GameManager 设为 Autoload,全局可访问 |
| 项目设置 | 输入映射、碰撞层、窗口大小 |
项目"工作室"已经搭建好了!下一节我们将开始实现怪物和路径系统——让怪物沿着预定的路线行走。
