JSON 存存档有个老问题:读写走 FileAccess,和引擎的资源系统完全脱节——没有类型检查、没有缓存、编辑器里看不到。Godot 的资源加载是开放链:任何类实现 ResourceFormatLoader 接口注册进去,load() 就能认出你的扩展名。这篇用一套存档格式走完整个流程。
加载链是怎么路由到你的 loader
ResourceLoader::load() 内部对某个路径的处理顺序(core/io/resource_loader.cpp):
load("user://slot1.sav")
│
├── 1. ResourceCache 查 path,命中直接返回(Resource 那篇的全局缓存)
│
├── 2. 按扩展名 "sav" 筛出认这个扩展名的 loader 列表
│
├── 3. 逐个调用 handles_type() / recognize 确认它认这个资源
│
└── 4. 调用 loader->load(),拿到 Resource 后进缓存,返回
扩展名只是第一道筛选,真正裁决权在 recognize 和 handles_type——所以你的 loader 可以只认扩展名的一部分文件(比如头几个字节不对就拒绝,退回给列表里的下一个 loader)。内置 loader 先注册,自定义的追加在后面,扩展名撞车时内置优先,这是保护机制也是约束:别指望抢占 .tscn 这类内置扩展名。
一套可运行的存档格式
资源类,就是普通 Resource 加 @export:
class_name SaveGame
extends Resource
@export var level: String
@export var playtime_sec: float
@export var inventory: Array[String]
Loader 实现四个方法:
class_name SaveGameLoader
extends ResourceFormatLoader
func _get_recognized_extensions() -> PackedStringArray:
return ["sav"]
func _handles_type(type: StringName) -> bool:
return type == &"Resource" or ClassDB.is_parent_class(type, &"SaveGame")
func _get_resource_type(path: String) -> String:
return "SaveGame" if path.get_extension() == "sav" else ""
func _load(path: String, original_path: String, use_sub_threads: bool, cache_mode: int):
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return FileAccess.get_open_error() # 返回 Error 是合法结果
var magic := f.get_32()
if magic != 0x53415645: # "SAVE",格式校验在这层做
return ERR_FILE_CORRUPT
var res := SaveGame.new()
res.level = f.get_pascal_string()
res.playtime_sec = f.get_double()
var n := f.get_32()
for i in n:
res.inventory.append(f.get_pascal_string())
return res
Saver 是镜像的三个方法:
class_name SaveGameSaver
extends ResourceFormatSaver
func _get_recognized_extensions(resource: Resource) -> PackedStringArray:
return ["sav"] if resource is SaveGame else PackedStringArray()
func _recognize(resource: Resource) -> bool:
return resource is SaveGame
func _save(resource: Resource, path: String, flags: int) -> Error:
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return FileAccess.get_open_error()
var sg := resource as SaveGame
f.store_32(0x53415645)
f.store_pascal_string(sg.level)
f.store_double(sg.playtime_sec)
f.store_32(sg.inventory.size())
for item in sg.inventory:
f.store_pascal_string(item)
return OK
_load 的返回类型是 union:Resource 或 Error。返回 Error 时 load() 把它转成失败并打印错误码——所以文件打不开时别自己 push_error 再返回 null,返回 FileAccess.get_open_error() 让上层统一处理。
注册的两个时机
编辑器里,@tool 且带 class_name 的 loader/saver 脚本会被自动注册(编辑器启动时扫描全局类,继承 ResourceFormatLoader / ResourceFormatSaver 的直接挂上),保存存档时在 FileSystem 面板右键就能选到你的格式。
导出后的运行时可没有编辑器帮这个忙,要显式注册。放在 autoload 的 _init() 里最稳:
# game_boot.gd(autoload)
func _init() -> void:
ResourceLoader.add_resource_format_loader(SaveGameLoader.new())
ResourceSaver.add_resource_format_saver(SaveGameSaver.new())
漏掉这一步的症状很典型:编辑器里一切正常,导出后 load() 报 "No loader found for resource"。这是 "在编辑器里跑得好好的" 类 bug 的高发区。
两个坑
缓存语义。 自定义 loader 加载出的资源一样进 ResourceCache,同路径第二次 load() 直接返回缓存实例。存档类资源通常不想被缓存(每次要读盘上新数据),调用方用 load(path, "", ResourceLoader.CACHE_MODE_IGNORE) 跳过。别在 loader 内部试图绕缓存——语义属于调用方。
加载中别递归加载自己。 loader 的 _load() 在加载线程上下文里跑,里面再同步 load() 同路径会造成递归死锁;加载其他路径则要留意线程安全(线程化那篇的约定对自定义 loader 一样适用)。存档格式尽量设计成自包含,一个文件一个资源。