Godot世界存档实战:类型化字典与右键移除工具
发布时间:2026/9/4 19:47:29 锦皓数字建站

你花了一整个下午在 Godot 编辑器里搭好一个小镇左边放了树右边放了箱子中间还铺了一条石板路。你运行游戏走了一圈觉得“世界有了”。但当你把游戏关掉第二天再打开那个花了一下午搭建的小镇还在吗如果你没有做任何存档逻辑答案很残酷树会恢复到最初位置箱子里的东西也会被重置。对于很多不像《死亡细胞》那样强调“每一把都是新冒险”的游戏来说玩家对世界的修改如果关掉游戏就消失那这个“世界”其实只存在于一次游戏进程里。这一期内容的主题就是解决这件事。你可能会觉得“世界存档”听起来很重但这里的实现思路并不复杂。更关键的是它会把另外两个看起来不相关的概念串起来类型化字典和右键移除工具。我建议你把这一集当成一个 Ep 2.5 来看——不是引入一个全新的大系统而是把前面学过的基础节点、场景、输入和信号合并成一个真正有实用价值的小工程。1. 先别急着写文件想清楚三个能力怎么配合很多人一开始接触“存档”时第一反应是去查“Godot 怎么保存文件”“Godot 怎么写入 JSON”。路径没错但缺少一个前置思考你保存的到底是什么如果是角色满血你保存一个数字如果是背包你需要保存一整个物品列表如果是玩家改造过的世界你要保存的可能是一堆“在什么坐标放了什么类型的物体”。物体不是一个简单数字它有类型、有位置、可能有额外状态。所以你不能直接说“把世界存下来”你要先把“世界”翻译成数据。在这个教程里我建议你把实现目标拆成三层输入层玩家点击/右键点击操作这个世界。数据层一个统一的结构记录世界上有哪些可编辑物体。表现层场景里的节点、精灵和碰撞体。存档的本质是把“表现层”压缩成“数据层”等下次启动时再从“数据层”恢复“表现层”。右键移除工具则正好相反玩家点击某个场景里的物体你不仅要把它从“表现层”删除还要把它从“数据层”里同步移除。不然下一次加载存档它又会“复活”。这就是我想先强调的主判断世界存档的真正难点不是文件读写而是你有没有一个稳定、可查询、可更新的“世界数据模型”。而类型化字典就是这个数据模型落到 GDScript 后最轻量的一种实现方式。1.1 这个组合为什么容易一起讲如果你跟着某个 Godot 入门系列看到这里前面的课程大概率已经讲过使用_process和_input处理玩家输入。使用packed_scene.instantiate()动态创建节点。使用queue_free()删除节点。但单独讲这些操作时你会觉得它们各自都很简单。真正难的是把它们拼起来后如何保证数据一致。右键移除工具就是一个非常典型的例子。假设你直接写了一个点击删除节点的功能鼠标右键点一下树的节点树就queue_free()了。游戏运行时会很流畅。可一旦你接下来想存档就会发现一个问题你已经把树从场景里删了但存档里没有记录“这棵树曾经存在于那里”或者记录里仍然认为那棵树还在。删除这个动作没有被同步进世界数据。所以世界存档、类型化字典、右键移除工具这三个内容本质上是一个“数据如何跟着操作变化”的问题。1.2 一张最小目标地图我不建议一开始就做成一个大的建造游戏。你可以做一个最小版本场景里有若干预设物体比如树、石头、箱子。玩家左键点击空地可以放置一个物体。玩家右键点击已放置的物体可以移除它。每次操作后把当前世界数据保存到user://world_save.json。游戏启动时读取存档如果存在就按数据重建世界。不需要有 UI、不需要有背包系统、也不需要区分多存档。只要能跑通上面这五步你对“世界存档”这个问题的理解就会超过大多数只看教程不写工程的人。2. 用“类型化字典”给世界数据立规矩在 GDScript 里Dictionary是一个使用频率极高的容器。它的写法很自由var d { name: tree, position: Vector2(100, 100) }你可以往里塞字符串、数字、Vector2、数组甚至再嵌套一个字典。这种自由度非常适合快速做原型但它也带来一个问题如果你不约束字典的键和值读取数据时很容易踩到Key not found或者类型和预期不一致。你可能听说过“数组可以写类型化数组比如Array[int]”。但 GDScript 里没有一套像 C#Dictionarystring, object那样显式的“类型化字典”语法。这里说的“类型化字典”是指你自己给字典定义一套清晰的键名和值类型让每个字段都知道自己该存什么。不依赖语言强制而是依赖你写的代码约定。我更愿意把这种做法称为“给字典写 schema”。如果你做过 Web 开发可以把它理解成数据库表结构。如果你的世界里有一千棵树、石头和箱子你总不能每次遍历时都要猜“这个字典里有没有name字段position是不是 Vector2”。2.1 世界数据的最小结构在我们这个最小工程里可以用一个总字典保存整份世界存档var world_data : { version: 1, objects: [] }其中version是存档版本以后你的游戏升级了旧存档可以通过版本号走迁移逻辑。objects是一个数组每一个元素都是一个字典。为了后面能准确移除物体我建议每个物体都加一个唯一 IDvar world_data : { version: 1, next_object_id: 0, objects: [] } func add_object(object_type: String, pos: Vector2) - int: var object_id : world_data[next_object_id] world_data[next_object_id] object_id 1 world_data[objects].append({ id: object_id, type: object_type, position: { x: pos.x, y: pos.y } }) return object_id这里有几个值得注意的设计选择。第一position没有直接存Vector2而是拆成{x: ..., y: ...}。因为 JSON 本身不原生理解 Godot 的Vector2它可能被序列化成数组或其他形式。为了让你自己完全掌握数据格式我建议在存档层尽量避免依赖引擎特有的序列化行为。你可以把它还原成Vector2但存的时候给它一个明确的普通文本结构。第二使用next_object_id自增而不是靠数组下标。删除某个物体后它的下标会变化如果你用下标当唯一标识后面很容易删除错对象。这其实就是“类型化”的一种体现每个字典的keys是固定的每个值都保持着明确可预期的类型。id是 inttype是 Stringposition.x是 floatposition.y是 float。2.2 为什么不能只保存节点你可能会想Godot 场景里的树已经是Tree.tscn导出的节点了我直接把整棵节点保存不可以吗从原理上说Godot 可以通过packedScene.pack()把节点打包成场景资源然后保存.tscn文件。这在某些编辑器插件里确实可行但不适合作为一般游戏的世界存档原因有几个场景资源保存的是节点树和属性里面会包含很多运行时对象直接存档容易变大并且脆弱。存档文件如果记录太多内部数据一旦你修改了游戏逻辑、新增了物体类型旧存档可能解析不出正确对象。你真正需要的往往不是“完整还原那一刻的编辑器场景”而是“还原玩家改动过的世界状态”。静态的树、石头如果不会变化本来就不需要存只要在场景里放一个初始模板就行。所以更合理的思路是只保存“玩家创建出来的、会变化的对象”。树旁边的一块石头不需要更新就不放进字典当玩家把它移除了你就要记录“这里少了一块石头”。在这个最小项目里我们把所有“可被玩家放置和移除的物体”放到一个专门的容器节点下比如ObjectContainer。只有这个容器里的子节点才和world_data[objects]对应。这样“房间里的初始家具”和“玩家动态建造的世界”就分开了。2.3 不同类型物体如何关联到场景当你有多种物体类型时可以用一个字典做“类型名到场景资源”的映射const OBJECT_SCENES : { tree: preload(res://objects/tree.tscn), rock: preload(res://objects/rock.tscn), chest: preload(res://objects/chest.tscn) }这样写的好处是你往存档里写入type: tree后加载时只要查OBJECT_SCENES就能找到对应场景。它也是一个字典但它的键值非常固定键是字符串值是 PackedScene。你不需要把整个PackedScene存进存档文件里。存档里只写“这里有一个 tree”等游戏启动时再从代码表里找到资源。这也是类型化字典的另一种应用程序里可用的对象资源是固定的而不是让玩家随便塞任意内容。3. 存档与加载从数据到文件再从文件回到场景当你把世界数据整理成字典后文件读写反而变成最简单的一步。不要先想着用二进制格式也不要一上来就引入 SQLite。对刚起步的存档需求一个 JSON 文件够了。3.1 为什么推荐 JSONJSON 至少有三个好处可读性强。保存后你可以直接打开文件检查数据结构是否符合预期。跨平台、跨版本稳定。它只包含普通字符串、数字、数组和对象。和 Godot 内置字典写法接近迁移成本低。确定是否用 JSON 前先问自己一个问题这份存档会不会非常大如果只是几百个物体每次全量读写 JSON 毫无压力。如果未来要做无缝大地图物体数量上十万才需要考虑分片、二进制或者数据库方案。在入门阶段不要为不存在的性能问题提前优化。3.2 Godot 4 的 JSON 文件写入下面的代码以 Godot 4 语法为例。如果你用的是 Godot 3API 有差异建议先查对应版本文档不要直接照搬。保存函数可以写成这样extends Node const SAVE_PATH : user://world_save.json var world_data : { version: 1, next_object_id: 0, objects: [] } func save_world() - void: var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file null: push_error(无法打开存档文件: SAVE_PATH) return file.store_string(JSON.stringify(world_data, \t)) file.close()这里使用user://作为存档路径。user://在不同操作系统上对应不同目录但对你来说不需要关心具体位置引擎会把它映射到游戏用户数据目录。你只需要知道这个目录是引擎给游戏本体保留的用户可写区域适合放存档、截图和日志文件。如果运行时你想看具体路径可以用print(OS.get_user_data_dir())这会打印出当前项目的用户数据目录。用它来排查“存档到底存到哪了”很有效。注意不要直接写res://下的存档。res://通常表示只读项目资源打包后不一定可写。存档应该写到user://。3.3 加载存档并通过字典重建场景加载分两步第一步读文件并解析 JSON第二步根据world_data清空旧场景并重建新节点。加载文件的伪代码如下func load_world() - bool: if not FileAccess.file_exists(SAVE_PATH): return false var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: push_error(无法读取存档文件) return false var text : file.get_as_text() file.close() var json : JSON.new() var err : json.parse(text) if err ! OK: push_error(存档解析失败: json.get_error_message()) return false if typeof(json.data) ! TYPE_DICTIONARY: push_error(存档格式错误) return false world_data json.data return true解析完成后world_data应该是一个字典。但你还需要验证它是否包含version、objects这些字段。如果你把“类型化字典”当作一种约定那么这里就要写几行防御代码防止玩家手动修改 JSON 或文件损坏。重建场景的代码逻辑也很直接onready var object_container: Node2D $ObjectContainer func rebuild_world() - void: # 先清理容器里旧物体 for child in object_container.get_children(): child.queue_free() # 从字典里读取物体列表 for obj in world_data.get(objects, []): if typeof(obj) ! TYPE_DICTIONARY: continue var type: String obj.get(type, ) if not OBJECT_SCENES.has(type): continue var packed_scene: PackedScene OBJECT_SCENES[type] var instance : packed_scene.instantiate() instance.position Vector2( obj.get(position, {}).get(x, 0.0), obj.get(position, {}).get(y, 0.0) ) # 把唯一 ID 放到物体节点上后面移除时会用到 if instance.has_method(setup_object): instance.setup_object(obj.get(id, -1), obj) object_container.add_child(instance)这里有几个细节新手特别容易忽略obj.get(position, {}).get(x, 0.0)的防御写法能避免某个物体数据不完整时直接报错。先queue_free()再在同一帧add_child可能会出现清空与重建重叠的情况。严格一点你可以在清空后先等待一帧再执行重建。对于学习阶段我会建议写成“清空容器”和“重建场景”两个独立函数中间通过await get_tree().process_frame分隔避免节点释放顺序问题。如果实例的根节点需要知道自己对应的id不要靠位置去反查而是通过setup_object之类的函数传进去。3.4 不是每次创建物体都要立刻写盘当你把“保存”按钮做出来之后就会开始琢磨应该什么时候保存最省事的做法是每次玩家放置或移除物体后自动保存。如果项目很小这样没问题。但如果玩家会频繁操作每次写 JSON 会带来不必要的开销。另一种做法是玩家操作后只更新内存里的world_data然后通过一个“脏标记”记录数据已被修改。等到玩家回到安全区、切换地图或关闭游戏时再统一保存。var save_dirty : false func mark_dirty() - void: save_dirty true在“小学教程”阶段我更推荐选择“手动触发保存”或者“操作后自动保存”。不用一上来就做复杂的延迟保存。真正需要频繁写盘时再考虑脏标记和节流。4. 右键移除工具点击不仅是一个动作更像一次数据操作现在我们回到玩家操作层。假设你已经实现了左键放置功能玩家可以在地图上放树、放石头。那么右键移除工具要做的就不是简单的“删除节点”而是三个动作同时发生检测玩家是否点到了某个可移除物体。从world_data[objects]里删掉对应记录。把场景里的对应节点删除。如果没有第 2 步下一次加载存档时被删除的物体会重新出现。这个现象非常经典很多新手遇到后会以为“存档没生效”其实存档数据根本没更新。4.1 输入层怎么写右键点击的处理最好放在_unhandled_input而不是_input这样当你以后加入 UI 时UI 已经消耗掉的右键点击不会继续触发游戏里的移除逻辑。一种常见写法func _unhandled_input(event: InputEvent) - void: if event is InputEventMouseButton and event.pressed: if event.button_index MOUSE_BUTTON_LEFT: _handle_place() elif event.button_index MOUSE_BUTTON_RIGHT: _handle_remove()_handle_place和_handle_remove需要拿到“鼠标所在的世界坐标”。在 2D 游戏里如果你把_unhandled_input挂在节点上坐标转换通常要基于当前Camera2Dfunc _handle_remove() - void: var world_pos : get_global_mouse_position() _try_remove_object_at(world_pos)4.2 物理检测会返回什么当场景里的物体都带有Area2D和对应的CollisionShape2D时你可以用物理空间检测坐标点附近是否有碰撞体。在 Godot 4 中常见写法如下func _try_remove_object_at(world_pos: Vector2) - void: var space_state : get_world_2d().direct_space_state var query : PhysicsPointQueryParameters2D.new() query.position world_pos query.collision_mask 1 # 与碰撞层有关按你的项目调整 query.collide_with_areas true query.collide_with_bodies false var results : space_state.intersect_point(query, 8) for result in results: var collider : result.get(collider) as Node if collider ! null and collider.is_in_group(placeable): _remove_object_from_scene_and_data(collider) return关于collision_mask如果你的物体放在第 2 层那这里要写2或用1 2。不要照抄数字第一件事是理解自己项目的图层分配。我更建议为可删除物体单独分配一个碰撞层。为什么推荐给物体加group(placeable)因为物理检测结果会包含所有参与碰撞的节点。如果你不做分组过滤右键一按可能会同时命中角色、敌人和箱子。你只需要让“玩家可以移除的世界物体”响应右键。4.3 删除对象时先拿 ID再删数据物理检测会返回collider也就是你点击到的那个Area2D节点。你需要在创建实例时把world_id存在它身上。一个简单做法是写一个公共脚本placeable.gdextends Area2D var world_id: int -1 func setup_object(id: int, data: Dictionary) - void: world_id id # 你可以根据 data 里的类型、颜色、自定义状态等做初始化然后右键删除时从collider.world_id拿到 ID再调用世界数据管理器的方法func _remove_object_from_scene_and_data(target: Node) - void: var id: int target.world_id # 从字典中移除 _remove_data_by_id(id) # 从场景中移除 target.queue_free() # 标记存档需要更新 mark_dirty() save_world()_remove_data_by_id里要做的事很直接func _remove_data_by_id(id: int) - void: var objects: Array world_data.get(objects, []) for i in range(objects.size()): var obj: Dictionary objects[i] if obj.get(id, -1) id: objects.remove_at(i) return这里通过唯一 ID 而不是坐标找到目标可以避免浮点数误差。之前计算出的坐标可能不是整数实际点击点也可能有微小偏移用坐标做删除条件会让你反复调试。4.4 场景节点和字典数据如何维护同步到这里你会看到这个工程里其实有两个“世界副本”一个在场景树里由多个Node2D/Area2D组成负责显示、碰撞和输入反馈。一个在内存里由world_data这个字典组成负责保存和逻辑判断。玩家删除物体后两个副本必须同时修改一个都不能漏。这也是“右键移除工具”为什么不能只是删除节点。如果以后你的物体有旋转、颜色、血量或者箱子物品列表它也可以作为统一字典里的字段。只要场景节点与字典数据通过world_id对应你就不会迷路。5. 新手最容易翻车的四个地方当我看到“类型化字典”和“世界存档”这两个词放在一起时我就知道后面大概率会有一堆和新手相关的问题。这里挑四个最常见的坑提前说。5.1 字典键名不一致读了半天没结果很多人刚入门时喜欢“省事”这次用Name下次用name再下次用type表示类型。Godot 的字典访问区分大小写dict[name]和dict[Name]是不同键。当你把世界数据保存成 JSON 后如果字段名五花八门后续代码会变得越来越绕。我给你一个最简单的约定所有键用snake_case也就是小写字母加下划线。比如object_id、position_x、is_open。然后在每次读取字典前先打印一次world_data观察键名是否符合预期。5.2 把 Vector2 直接塞进 JSON在 GDScript 内部Vector2是一个很常见的类型。但 JSON 只有字符串、数字、布尔、数组、对象。当你把 Vector2 放进字典再执行JSON.stringify()时引擎可能会用某种方式把它转换掉比如变成一个数组或对象。可是当你读取 JSON 后它可能不会再自动还原成Vector2。所以我在上面的数据模型里特意把坐标写成position: { x: 100.0, y: 200.0 }这样做的最大好处不是“性能更好”而是数据格式一旦固定下来就不会受不同 Godot 版本序列化规则变化的影响。你在读取时再手动转换成Vector2即可。如果你碰到底层引擎升级后的存档兼容问题这种“只保存基础类型”的习惯能省下很多麻烦。5.3 重建世界时旧节点还没被清空在rebuild_world()里我们通常先写for child in object_container.get_children(): child.queue_free()但queue_free()并不是马上移除节点它只是把节点排到删除队列等当前帧结束后再处理。如果你立刻执行下面的创建新节点循环可能新节点已经加进容器旧节点还在容器里。这时场景树里会出现新旧叠加的瞬间。对于偶尔触发一次的存档加载这可能不明显但对于频繁的重载操作你可能会看到奇怪的闪烁或重复物体。解决方式有很多种。你可以把创建新节点的过程延后一帧func rebuild_world() - void: for child in object_container.get_children(): child.queue_free() await get_tree().process_frame for obj in world_data.get(objects, []): ...也可以更粗暴地先移除旧节点再 await但对于学习项目await方式已经够直观且安全。5.4 看到 Godot 3 的教程就照搬 API很多网上资料还停留在 Godot 3File、BUTTON_LEFT、instance()。但 Godot 4 已经改成了FileAccess、MOUSE_BUTTON_LEFT、instantiate()。如果你把 Godot 3 的代码复制进 Godot 4编辑器会直接标红。解决方式不是死记 API而是每次编译前先看错误提示。遇到和文件、鼠标事件、场景实例化相关的报错优先去查官方文档的对应版本。如果你打开的是中文网站里的老代码请先留意文章底部的版本说明。这不是什么高深问题但真的会影响你后面排查问题的速度。6. 存档后打开游戏没有变化可以按这条链路排查“我知道要加载存档了代码也写了但运行后世界还是初始状态。”这是很常见的反馈。接下来可以按下面的顺序排查不要一上来就怀疑引擎。6.1 第一步看存档文件是否真的产生了在项目里打印OS.get_user_data_dir()然后去那个目录看有没有world_save.json。如果文件不存在说明保存函数可能没被调用或者保存路径写错了。请先在相关函数开头加一个print(save_world called)。如果文件存在但内容为空说明文件写入失败常见原因是user://目录不可写、文件被占用等。把存档文件打开用肉眼看一看结构。你不应该在调试时写一堆复杂代码先靠这个最直接的方法确认数据层是否正常。6.2 第二步检查加载函数是否真的被调用很多人在Main.gd的_ready里写func _ready() - void: load_world() rebuild_world()但可能load_world()返回了false说明文件不存在或解析失败而你根本没看返回结果。更稳妥的写法是显式处理返回值func _ready() - void: if load_world(): rebuild_world() else: # 没有存档创建一份默认世界 build_initial_world()如果你不打印返回值那么存档解析失败时世界就会保持在新建状态。你还会误以为“存档根本没有生效”。6.3 第三步检查物体容器的子节点和 parent当你确认存档文件存在、加载函数也被调用后再看重建世界有没有真正执行。一个常见坑是onready var object_container: Node2D $ObjectContainer如果路径写错object_container会为 null。你调用add_child()时不会报错不会报“Invalid call. Nonexistent function”或者空实例错误。看输出面板有没有红色错误。还有一个更隐蔽的点你在_ready里调用rebuild_world()但如果这个函数所在节点的子节点还没有 ready那么清空操作可能会误清空其他内容。我会建议把保存和恢复逻辑放在一个专门的管理节点里而不是放在某个具体物体的脚本里。6.4 第四步用一条日志确认节点数量和 id当存档数据正确、加载函数也执行后最后检查还原出来的节点是否正确。你可以在rebuild_world()末尾加print(rebuild world, objects count , world_data.get(objects, []).size()) print(container children count , object_container.get_child_count())如果两个数量对不上可能就是某些type没有对应的PackedScene或者某些字典项格式不完整。这也是为什么我们在类型化字典里要固定type字符串。整个排查思路可以沉淀成一个口诀先看数据有没有生成再看加载有没有调用再看节点有没有创建最后看类型能不能匹配。最后的重复训练一次能跑通存档加载会让你觉得“原来这么简单”。但真正的收获是你反复做完放置、右键移除、保存、再重启游戏之后开始形成一种“数据驱动场景”的感觉。以后再遇到复杂系统比如背包、任务、NPC 位置、玩家建造你都不会再问“怎么保存这些节点”而是先问这个功能面对的数据结构是什么它应该由哪些字段组成玩家操作之后哪里是数据源哪里只是视图这才是我想让你学到的。类型化字典不是 Godot 里的某个神秘神器它是一套约束自己的方法右键移除工具也不只是一个输入事件它逼着你同时更新内存和场景树世界存档更不是简单的文件读写它让“玩家修改过的世界”第一次拥有了跨会话的生命周期。你下一步可以做的是把箱子变成一个可以打开、里面存着道具的物体。试着往物体字典里加一个inventory字段然后看看存档文件会怎么记录它。等这个流程走通你对世界存档的理解就不再只是“保存位置”了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。