教程一 · 第 2 章:核心心智模型——节点、场景、脚本
这一章不做游戏,只建立概念。Godot 学起来快,很大程度是因为它的核心概念只有三个,而且贯彻始终:节点(Node)、场景(Scene)、信号(Signal)。
2.1 节点:一切皆节点
打开 Godot,你操作的每一个东西都是节点(Node)。玩家是节点,子弹是节点,摄像机、灯光、碰撞箱、血条文字、按钮、背景音乐……全是节点。
每个节点:
- 有一个名字
- 有一组属性(位置、旋转、缩放、可见性……)
- 可以接收回调(进场景时、每帧、收到输入时)
- 可以嵌套子节点:节点组成树
节点本身是基类,真正用的是它的子类,先认识这几个最常用的:
Node(最基础:无位置概念,适合做逻辑控制器/管理器)└── CanvasItem(能画在 2D 画布上) ├── Node2D(有位置/旋转/缩放,2D 万物之基) │ ├── CharacterBody2D 角色身体(玩家、敌人,自己控制移动) │ ├── RigidBody2D 刚体(受物理力控制,会翻滚、弹跳) │ ├── StaticBody2D 静态碰撞体(地面、墙壁) │ ├── Area2D 区域(触发器:拾取物、伤害区、传感器) │ ├── Sprite2D 贴图 │ ├── CollisionShape2D 碰撞形状(给上面那些 body 配形状) │ ├── Camera2D 摄像机 │ └── AnimationPlayer 动画播放器 └── Control(UI 体系:按钮、血条、菜单的基类) ├── Button / Label / ColorRect └── Control 容器(HBoxContainer、VBoxContainer……)3D 版本同理:Node3D、CharacterBody3D、Camera3D、DirectionalLight3D,命名规律非常整齐。学会 2D 后迁移到 3D 几乎零成本。
2.2 节点树:组合大于继承
一个玩家角色在 Godot 里通常长这样:
Player(CharacterBody2D) ← 负责移动逻辑├── Sprite2D ← 负责显示贴图├── CollisionShape2D ← 负责碰撞形状├── Camera2D ← 负责跟随└── AnimationPlayer ← 负责播放动画这是 Godot 哲学的第一条:用组合,不用巨型类。
“玩家”不是一个几千行的类,而是一个功能节点的组合:想换贴图就换 Sprite2D 子节点,想加血条就挂个 Control,想加脚步声就加 AudioStreamPlayer。每个节点只管一件事。
在场景面板里:
- 拖动能调整父子关系
- Ctrl+D 复制节点
- 选中节点后在检查器改属性
- 锁图标可以防止误拖;眼睛图标控制可见
2.3 场景:可保存、可复用的节点树分支
场景(Scene)= 一棵节点树 + 保存成 .tscn 文件。
场景是 Godot 的封装单位,类似其他引擎里的 Prefab,但更彻底——整个游戏也是一个场景。
关键能力:场景可以实例化(Instancing)。把 bullet.tscn(子弹场景)拖进主场景,它就成了一棵子树;运行时代码也能动态生成 100 个子弹实例。改一次 bullet.tscn,所有实例的基础形态跟着变;每个实例又能单独改位置、传不同伤害值。
典型的项目结构:
main.tscn 主场景(关卡)├── TileMap / 地面 StaticBody├── Player(实例 player.tscn)├── Enemy × N(实例 enemy.tscn)├── SpawnPoint(Marker2D)└── HUD(实例 hud.tscn)新手最该养成的习惯:每个独立的”东西”做成一个场景。 玩家一个场景、敌人一个场景、子弹一个场景、UI 一个场景。不要把所有东西堆在一个场景里。
2.4 脚本:挂在节点上的 C# 类
脚本不是凭空存在的——每个脚本都挂在某个节点上,脚本类继承该节点的类型。
using Godot;
// 挂在 CharacterBody2D 节点上:这个脚本就能用 Velocity、MoveAndSlide()public partial class Player : CharacterBody2D{ public override void _Ready() { }}// 挂在 Button 上:就能用 Pressed 事件、Text 属性public partial class StartButton : Button { }// 挂在 Node 上:纯逻辑管理器(计分板、游戏流程)public partial class GameManager : Node { }这决定了一个重要习惯:先想”这个脚本要操控什么节点”,再决定挂在哪、继承谁。移动逻辑挂在 CharacterBody2D 上;按钮响应挂在按钮上;全局管理挂一个裸 Node(用 autoload,第 7 章讲)。
2.5 两个最重要的生命周期回调
Godot 每帧都会调用节点上约定好名字的方法,你重写(override)它们来注入逻辑。90% 的代码写在这两个里:
public override void _Ready(){ // 节点和它的所有子节点都进入场景后,执行一次 // 用来:初始化变量、找子节点、连接信号、读存档}
public override void _Process(double delta){ // 每帧渲染前执行;delta = 上一帧经过的秒数 // 用来:非物理移动、UI 更新、计时器累加}
public override void _PhysicsProcess(double delta){ // 固定帧率(默认每秒 60 次)调用 // 用来:所有物理相关——角色移动、碰撞、射线检测}为什么要 delta? 帧率不固定,玩家电脑可能跑 60 帧也可能 240 帧。“每帧 +1”在不同机器上速度差 4 倍。正确写法是”每秒 +多少”:
private double _timer;
public override void _Process(double delta){ _timer += delta; // 按真实秒数累加,与帧率无关 Position += Vector2.Right * 100f * (float)delta; // 每秒右移 100 像素}注意 C# 是强类型:
delta是double,而 Godot 的Vector2、Position用float,相乘处要显式(float)delta。新手红线十有八九是它。
2.6 节点间怎么找彼此
Godot 不是单例满天飞,节点之间有明确的路径关系。假设树是:
Main├── Player│ └── Sprite2D└── Enemies └── Enemy1① 在自己脚本里拿子节点(最常用)
private Sprite2D _sprite;
public override void _Ready(){ // 写法 A:GetNode<T>("路径") _sprite = GetNode<Sprite2D>("Sprite2D");
// 写法 B:[Export] 在编辑器里拖拽指定,解耦、推荐给可变结构}② [Export]:把变量暴露到检查器
[Export] public float MoveSpeed = 300f;[Export] private PackedScene _bulletScene; // 拖入 bullet.tscn[Export] public NodePath TargetPath; // 拖入任意节点的路径编辑器里选中节点,检查器会出现这几个字段,可以直接填数、拖场景、拖节点。同一个脚本挂在不同实例上,可以配不同参数——这是 Godot 工作流的核心甜头。
③ 分组(Group):给节点贴标签
选中节点 → 右侧 节点 → 分组 面板,加一个组名比如 "enemy"。然后:
// 全场景找所有 enemyforeach (Node enemy in GetTree().GetNodesInGroup("enemy")){ GD.Print(enemy.Name);}适合爆炸时”找到所有敌人扣血”这种不该知道彼此具体位置的关系。
④ 父节点 / 根节点
GetParent(); // 父节点GetTree().CurrentScene; // 当前主场景根GetTree().Root; // 整个游戏窗口的最底层 Viewport⚠️ 尽量不要写 GetNode("../../../Player/Weapon") 这种跨半棵树的路径,一改树结构全崩。跨系统通信用信号或 autoload,下一节和第 5 章细讲。
2.7 信号:节点之间喊话的机制
信号(Signal)= 广播:“我出事了,谁关心谁来听”。 发出方完全不关心有没有人在听、是谁在听。这是 Godot 解耦的灵魂。
内置信号例子:
Button.Pressed:按钮被点Area2D.BodyEntered:有物体进入区域Timer.Timeout:计时器归零CharacterBody2D没有受伤信号——自定义信号是家常便饭:
public partial class Player : CharacterBody2D{ // 声明自定义信号(命名用过去式:事件"已经发生") [Signal] public delegate void HealthChangedEventHandler(int current, int max); [Signal] public delegate void DiedEventHandler();
private int _hp = 100;
public void TakeDamage(int amount) { _hp -= amount; EmitSignal(SignalName.HealthChanged, _hp, 100); // 发射 if (_hp <= 0) EmitSignal(SignalName.Died); }}别的节点(比如血条 UI)订阅它:
public override void _Ready(){ var player = GetNode<Player>("../Player"); player.HealthChanged += OnHealthChanged; // C# 事件式订阅,最常用}
private void OnHealthChanged(int current, int max){ Text = $"HP {current}/{max}";}也可以在编辑器里选 UI 节点 → 节点面板 → 信号 双击连接,自动生成回调方法。新手先会代码订阅,编辑器连接在第 5 章系统讲。
记住信号的判断标准:A 发生了事,B/C/D 需要反应,但 A 不应该知道 B/C/D 的存在——用信号。
2.8 第一个有画面的脚本:移动一个方块
把概念串起来,做个能看见的东西。
- 新建场景,根节点选 Node2D,保存为
move_demo.tscn - 给它加子节点 ColorRect?不对——2D 视口里加一个 Sprite2D,用图标:Godot 左上角图标菜单 → Node2D 下加
ColorRect是 UI,这里用最简单的办法:加子节点Polygon2D,在检查器 Polygon 里给四个点(-32,-32 / 32,-32 / 32,32 / -32,32),Color 选红色 - 根节点挂脚本
MoveDemo.cs:
using Godot;
public partial class MoveDemo : Node2D{ [Export] public float Speed = 300f;
public override void _Process(double delta) { Vector2 dir = Input.GetVector("ui_left", "ui_right", "ui_up", "ui_down"); Position += dir * Speed * (float)delta; QueueFree(); // ← 先别写这行,下章讲;这里只演示移动 }}Input.GetVector用的是 Godot 内置输入动作名(方向键和 WASD 默认已绑定ui_left等)。F6 运行,按方向键/WASD,红方块移动- 试着在检查器把 Speed 改成 600,再跑——没改一行代码就变快了,这就是
[Export]的意义
2.9 本章小结
- 节点是最小功能单元;节点树用组合拼装复杂对象
- 场景是存盘的节点树,可被无限实例化;每个独立物件做成独立场景
- 脚本继承所挂节点的类型,
_Ready初始化一次,_Process/_PhysicsProcess每帧执行,位移记得乘delta [Export]把参数暴露给编辑器;GetNode<T>取子节点;分组按标签找节点- 信号用于跨节点广播事件,是解耦的核心手段
2.10 作业
- 让方块移动时,按住 Shift 速度翻倍(提示:
Input.IsKeyPressed(Key.Shift),或者更好的做法——先别查,自己想想条件表达式怎么写)。 - 加一个
Label子节点,每帧显示方块当前坐标(提示:GetNode<Label>("Label"),Text = Position.ToString())。 - 声明一个自定义信号
MovedEventHandler(Vector2 newPos),在位置变化时发射(本章没给完整例子,故意让你照着 2.7 的写法自己推一遍,这是从”看懂”到”会写”的关键一步)。
第 3 章我们做第一个完整小游戏 Pong,把输入、碰撞、信号、场景实例化全部用起来。
《Godot C# 零基础》系列导航
⬅️ 上一篇:Godot C# 零基础教程 01:开篇与环境搭建 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:Godot C# 零基础教程 03:第一个完整游戏 Pong