技术博客

自定义 ResourceFormatSaver/Loader:让引擎认一种新资源类型

第 103 / 149 篇 · 2025-10-26 · godot · 资源管线GDScript工程实践

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 一样适用)。存档格式尽量设计成自包含,一个文件一个资源。

load() 到自定义 loader 的路由链 load(path) user://slot1.sav ResourceCache 命中即返回 未命中 扩展名筛选 认 .sav 的 loader 列表 handles_type + recognize _load() 解析 返回 Resource 或 Error Saver 链(保存方向) ResourceSaver.save(res, path) → 按资源类型找 saver → _save() 写盘 注册时机 编辑器:@tool + class_name 自动注册(编辑器启动时扫描全局类) 导出运行时:autoload 的 _init() 里显式 add_resource_format_loader 漏注册的症状:编辑器正常,导出后报 No loader found for resource