📒

[Godot] Resource を JSON として保存するやり方メモ。

2025/03/08に公開

きっかけ

Resource をセーブデータにしようかと思いましたが、汎用性・互換性を考えて JSON に変換して保存しようと考えました。ただ、そのままリソースを JSON.stringify しても復元できる状態で保存できなかったので自作しました。(※運用実績は無いです)

作る際につまずいた点

  • JSON.stringify に Resource を渡しても展開されず "<Resource#-9223371945368615100>" のような文字列になる。
    • => get_property_list() 関数でプロパティ一覧を取得して自前で Dictionary 型にいったん変換しました。
  • get_property_list() 関数だと自分が定義したプロパティ以外も取得されてしまう。
    • => script に関する get_property_list() を間引きました。
  • ネストしたリソースが存在する場合に再帰的にパースする必要があった。
    • => 各リソースに parse_mapping() 関数を定義し、特定のプロパティが子リソースである場合の判断を追加。

できたもの

src/helpers/resource_helper.gd
extends Node
class_name ResourceHelper

static func parse(klass, json_or_props) -> Resource:
    var resource = klass.new()
    var props = JSON.parse_string(json_or_props) \
        if typeof(json_or_props) == TYPE_STRING \
        else json_or_props
    var mapper = klass.parse_mapping() \
        if klass.has_method('parse_mapping') else {}
    for key in props:
        if mapper.has(key):
            for hash in props[key]:
                var child = parse(mapper[key], hash)
                resource.get(key).push_back(child)
        else: resource.set(key, props[key])
    return resource

static func json(resource: Resource) -> String:
    return JSON.stringify(dict(resource))

static func dict(value: Variant) -> Variant:
    match typeof(value):
        TYPE_OBJECT:
            return properties_of(value).reduce(func(h, prop):
                h[prop] = dict(value.get(prop))
                return h, {})
        TYPE_ARRAY:
            return value.map(func(v): return dict(v))
        _: return value

static func properties_of(resource: Resource) -> Array:
    var script = resource.get_script()
    var s_arr = script.get_property_list().map(func(p): return p.name)
    var p_arr = resource.get_property_list().map(func(p): return p.name)
    return p_arr.filter(func(x): return not s_arr.has(x)).filter(func(x):
        return x != 'script' and x != 'Built-in script' \
            and (not x.ends_with('.gd')))

使い方

extends Resource
class_name FooResource

@export var name : String = 'test'
@export var bars : Array[BarResource]

static func parse_mapping() -> Dictionary:
    return { 'bars': BarResource }
var foo_resource = FooResource.new()

# Resource => JSON
var json = ResourceHelper.json(foo_resource)

# JSON => Resource
var parsed = ResourceHelper.parse(FooResource, json)

テストコード

test/helpers/test_resource_helper.gd
extends GutTest

class TestResource:
    extends Resource
    var flag : bool = true
    var name : String = 'test'
    var items : Array = [1.0, '2', null]
    var children : Array[ChildResource] = [
        ChildResource.new()
    ]
    static func parse_mapping() -> Dictionary:
        return { 'children': ChildResource }

class ChildResource:
    extends Resource
    var foo : float = 1.234
    var bar = null
    var children : Array[GrandChildResource] = [
        GrandChildResource.new()
    ]
    static func parse_mapping() -> Dictionary:
        return { 'children': GrandChildResource }

class GrandChildResource:
    extends Resource
    var piyo : String = 'bar'

func test_init():
    var res = TestResource.new()
    assert_eq(res.flag, true)
    assert_eq(res.children[0].foo, 1.234)

func test_properties_of():
    var res = TestResource.new()
    assert_eq(ResourceHelper.properties_of(res),
        ['flag', 'name', 'items', 'children'])
    assert_eq(ResourceHelper.properties_of(res.children[0]),
        ['foo', 'bar', 'children'])
    assert_eq(ResourceHelper.properties_of(res.children[0].children[0]),
        ['piyo'])

func test_json():
    var res  = TestResource.new()
    var dict = ResourceHelper.dict(res)
    var json = ResourceHelper.json(res)
    var parsed = JSON.parse_string(json)
    assert_eq(res.name, dict.name)
    assert_eq(res.name, parsed.name)
    assert_eq(res.items[0], dict.items[0])
    assert_eq(res.items[0], parsed.items[0])
    assert_eq(res.children[0].foo, dict.children[0].foo)
    assert_eq(res.children[0].foo, parsed.children[0].foo)
    assert_eq(
        res.children[0].children[0].piyo,
        dict.children[0].children[0].piyo)
    assert_eq(
        res.children[0].children[0].piyo,
        parsed.children[0].children[0].piyo)
    assert_eq(dict.items, parsed.items)
    assert_eq(dict.children, parsed.children)
    assert_eq(dict.children[0].children, parsed.children[0].children)
    assert_eq(dict, parsed)

func test_parse():
    var res  = TestResource.new()
    var json = ResourceHelper.json(res)
    var parsed = ResourceHelper.parse(TestResource, json)
    for key in ResourceHelper.properties_of(res):
        if key == 'children':
            var child = res.children[0]
            assert_eq(child.foo, parsed.children[0].foo)
            var grand_child = child.children[0]
            assert_eq(grand_child.piyo, parsed.children[0].children[0].piyo)
        else:
            assert_eq(res.get(key), parsed.get(key))

余談

今回初めて知りましたが、JSON を JSON.parse_string で復元すると数値は必ずfloat型になる仕様らしいです。
なので、リソースでint型の変数 1 を作成してもJSONから戻す際に 1.0 になりますが、リソースの変数に代入したらキャストしてくれるので問題はなかったです。
https://github.com/godotengine/godot/issues/75821

Discussion