1869 字
9 分钟
Godot C# 零基础教程 06:Resource 数据驱动、动态实例化与存档

教程一 · 第 6 章:Resource 数据驱动、动态实例化、存档#

前 5 章解决了”场景里有什么、怎么动、怎么通信”。本章解决两个工程问题:数据怎么组织(10 种武器、50 种敌人难道写 50 个类?)和进度怎么持久化(关掉游戏再打开还在)。

6.1 Resource:Godot 被低估的最强概念#

Resource(资源)= 可序列化为文件、可在 Inspector 里编辑、可复用的数据对象。 纹理是 Resource、音频是 Resource、PackedScene(场景文件)也是 Resource。而你可以自定义 Resource 类型——这是 Godot 数据驱动开发的核心。

自定义一个武器数据#

scripts/resources/WeaponData.cs:

using Godot;
[GlobalClass] // 关键:让 Godot 认识这个类型,Inspector 里才能创建和选择
public partial class WeaponData : Resource
{
[Export] public string WeaponName = "New Weapon";
[Export] public int Damage = 10;
[Export] public float FireInterval = 0.3f;
[Export] public float BulletSpeed = 800f;
[Export] public Texture2D Icon = null!;
[Export] public PackedScene BulletScene = null!;
}

在文件系统面板右键 → 新建资源 → 选 WeaponData,存成 weapons/pistol.tres;再建 shotgun.tres、rifle.tres。双击每个 .tres,在检查器里填不同数值。没有新类,没有 if-else,只有数据资产。

.tres 是文本格式资源(Git 可 diff,开发期用);.res 是二进制(发布后体积小)。场景文件同理:.tscn 是文本。

使用它#

public partial class Weapon : Node2D
{
[Export] public WeaponData Data = null!; // 拖入 pistol.tres
private float _cooldown;
public override void _Process(double delta)
{
_cooldown -= (float)delta;
if (Input.IsActionPressed("fire") && _cooldown <= 0f)
{
Fire();
_cooldown = Data.FireInterval;
}
}
private void Fire()
{
var bullet = Data.BulletScene.Instantiate<Bullet>();
bullet.GlobalPosition = GlobalPosition;
bullet.Setup(Vector2.Right, Data.BulletSpeed, Data.Damage);
GetTree().CurrentScene.AddChild(bullet); // 加进当前场景,而不是武器自己
}
}

换武器 = 换一个拖入的 .tres 文件。做 50 种敌人、20 个关卡配置,全是这个套路。逻辑写一遍,数据做成资产——这是新手脚本堆砌和正经项目之间最大的思维差距。

Resource 的共享语义(也是坑)#

Resource 默认按引用共享:100 个敌人引用同一个 enemy_stats.tres,运行时改一份,全部变。两种利用方式:

  • 想让所有敌人同时狂暴?改共享 Resource,天然”全局数据”
  • 想要实例独立数据?代码里 stats.Duplicate() 复制一份,或每个敌人单独挂 .tres

6.2 动态实例化:子弹、敌人、特效#

前面 Pong 的球是预先摆在场景里的。真实游戏里子弹要在开火瞬间生成:

完整模板#

public partial class Bullet : Area2D
{
private Vector2 _velocity;
private int _damage;
// 用普通方法做"构造参数":Godot 节点不能用 new 传参(要先 Instantiate 再加树)
public void Setup(Vector2 direction, float speed, int damage)
{
_velocity = direction.Normalized() * speed;
_damage = damage;
}
public override void _PhysicsProcess(double delta)
{
GlobalPosition += _velocity * (float)delta;
}
public void OnTimerTimeout() => QueueFree(); // 配个 Timer 子节点,5 秒自毁
}

生成方记住三步顺序:

var bullet = _bulletScene.Instantiate<Bullet>(); // ① 实例化(还不在树里)
bullet.Setup(dir, speed, damage); // ② 注入数据(此时 _Ready 还没跑)
GetTree().CurrentScene.AddChild(bullet); // ③ 加进场景树(_Ready 此刻才执行)

⚠️ 常见错误:先 AddChild 再 Setup,_Ready 读到的还是默认值。顺序是实例化 → 注入 → 入树。

对象池(性能进阶)#

Instantiate 有开销,弹幕游戏每秒几百发子弹时用对象池:预先实例化一批,用时从池里取(移出池节点/重设位置),不用时 RemoveChild 回池而不是 QueueFree。新手阶段先不用做,等 Instantiate 出现在性能分析器顶部再优化(第 8 章讲调试器)。

6.3 节点查找在动态生成下的正确姿势#

子弹生成在 CurrentScene 下,敌人怎么找玩家?回顾第 5 章决策树:用组。

Player._Ready
AddToGroup("player");
// Enemy 或子弹需要时(_Ready 缓存,不要每帧找)
private Player _player = null!;
public override void _Ready()
{
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
_player = GetTree().GetFirstNodeInGroup("player") as Player;
}

6.4 存档:三种层次#

层次 1:Godot Resource 存二进制/文本(最简单)#

把存档数据定义成 Resource:

[GlobalClass]
public partial class SaveData : Resource
{
[Export] public int Level = 1;
[Export] public int Coins = 0;
[Export] public float MaxHp = 100f;
[Export] public string PlayerName = "";
[Export] public Godot.Collections.Array<bool> LevelsCleared = new();
}

存:

var data = new SaveData { Level = 3, Coins = 42, MaxHp = 80f };
string path = "user://save.tres"; // user:// 是 Godot 托管的用户目录,跨平台
ResourceSaver.Save(data, path);

读:

if (ResourceLoader.Exists(path))
{
var data = ResourceLoader.Load<SaveData>(path);
GD.Print(data.Coins);
}
  • 优点:三行代码、类型安全、Inspector 可看
  • 缺点:改类结构后旧存档可能失配;玩家可以随手改 .tres 文本作弊;不适合存世界中每个物件的状态
  • user:// 的物理位置:Windows %APPDATA%/Godot/app_userdata/项目名,Linux ~/.local/share/godot/app_userdata/项目名,macOS 在 ~/Library/Application Support

层次 2:JSON(可读、可迁移、可控)#

public class SaveData
{
public int Level { get; set; }
public int Coins { get; set; }
public Vector2 Position { get; set; } // System.Text.Json 不能直接序列化 Godot Vector2
}

⚠️ Godot 结构体(Vector2、Color)和 System.Text.Json 不来电,用普通 DTO(float PosX/PosY)转换,或用 JsonUtility:

var godotObj = new Godot.Collections.Dictionary
{
["level"] = 3,
["pos"] = new Vector2(10, 20) // Godot 自己的 JSON 类认识这些类型
};
string json = Json.Stringify(godotObj);
var parsed = Json.ParseString(json).AsGodotDictionary();

实操推荐:用 Godot 的 Json.Stringify + Dictionary 写,或用 NuGet 装 Newtonsoft.Json(配置一个转换器处理 Vector2)。中小项目层次 1 足够。

层次 3:“收集—存储—恢复”完整模式#

真正难的不是写文件,是存什么、怎么恢复。给需要持久化的节点统一接口:

public interface ISaveable
{
string SaveId { get; }
Godot.Collections.Dictionary Save();
void Load(Godot.Collections.Dictionary data);
}

敌人、箱子、门实现它,SaveManager 遍历组 "saveable" 收集字典、写 JSON;读档时按 SaveId 匹配恢复。玩家位置、已捡物品(存”已移除的节点 id 集合”比存”场上所有物品”聪明)、开关状态都能覆盖。这是正式项目的做法,理解思路即可,需要时回来实现。

存档安全#

  • 本地存档没有真正的防作弊,只能提高门槛:发布版存 .res 二进制 + 加 HMAC 校验(密钥在代码里,破解成本高一点)
  • Steam 云存档等以后上平台再管
  • 多存档槽:save_01.tres、save_02.tres……

6.5 配置数据放哪:一张选型表#

数据载体
武器/敌人/物品数值自定义 Resource(.tres)
游戏平衡常量(重力、暴击率)[Export] 在场景上,或单独 balance.cfg Resource
本地化文本Godot 翻译系统(CSV/PO)
按键绑定InputMap 运行时修改 + 存 user://
玩家进度/设置SaveData Resource 或 JSON
大量结构化数值(对话树、任务表)JSON/CSV 导入,或插件(如对话插件)
环境变量/密钥不要进 Git,配置文件 + .gitignore

6.6 一个贯穿例子:给平台游戏加”武器系统”作业#

目标:把本章知识用一遍。

  1. 建 WeaponData(名字、伤害、射速、弹速)+ 手枪/霰弹两个 .tres
  2. Weapon.cs 挂在 Player 子节点,[Export] WeaponData,按 J 开火
  3. bullet.tscn:Area2D + CollisionShape2D + 小圆 Polygon2D,飞行 + 碰墙 QueueFree + 命中敌人调 TakeDamage
  4. 敌人 enemy.tscn:CharacterBody2D 左右巡逻(用 Mathf.Sin(Time.GetTicksMsec()) 或两个 Marker2D 之间 MoveToward),HP 用 [Export],归零发 Died 信号并 QueueFree
  5. 金币数和当前关卡写进 SaveData,按 F5 快速存、F9 快速读

6.7 本章金句#

  • 逻辑写一遍,数据做成资产
  • 实例化三步走:实例化 → 注入 → 入树
  • 销毁节点用 QueueFree(),不是置 null
  • user:// 是唯一该写存档的地方
  • 存档设计先想”恢复时怎么对上号”,再想存什么

第 7 章回到玩家可见的东西:UI 系统与游戏流程(主菜单、暂停、血条、设置界面、场景过渡)。


《Godot C# 零基础》系列导航

⬅️ 上一篇:Godot C# 零基础教程 05:信号与节点通信 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:Godot C# 零基础教程 07:UI 与游戏流程

Godot C# 零基础教程 06:Resource 数据驱动、动态实例化与存档
https://021028.xyz/posts/default/166/
作者
021028
发布于
2026-10-14
许可协议
CC BY-NC-SA 4.0