上期我们把 Godot 场景里的基础交互做通了这次进入玩法真正落地前最关键的一环世界状态能不能存下来游戏里成堆的物品和节点怎么用更安全的姿势管理以及在一个由玩家自己搭建的世界里如何用“右键移除”而不是写一堆临时按钮去删东西。如果你正在做沙盒、建造、存货翻包、楼层或关卡编辑这类玩法这一期的三个点基本都会遇到世界存档、类型化字典、右键移除工具。内容我会按实际做功能时的顺序走先把 Godot 的类型化字典讲清楚再用字典设计存档结构最后在场景里实现右键移除节点并给出可运行的完整 GDScript 代码。1. 本期功能范围与核心技术点能力项说明引擎版本以 Godot 4.x 为例GDScript 编写难度定位已掌握节点、信号、基础 scene 操作的中级阶段核心语法Dictionary 类型化、Variant 保存、节点树扫描、右键输入事件涉及场景世界关卡状态保存、放置物管理、编辑器式右键删除扩展能力存档槽位、物品数量、位置恢复、批量删除是否需要 C不需要全部使用 GDScript外部依赖无不依赖第三方库适合读者从“能做小 demo”走向“能连续开发和调试功能”的 Godot 学习者World Save 和右侧工具的关键点不是某个神奇 API而是把运行时数据与节点实际状态分开管理。World 内部真正起作用的往往是一条记录节点信息的列表而不是直接保存整个 scene。下面会先让你有一种“无需绑定运行时”的思维。2. 世界存档的数据边界与适用玩法世界存档是一个模板可变的玩法不能笼统地只理解为写一个save函数。在开发中下面这些玩法需要单独的存档机制玩家已经搬动过的砖块、宝箱或地牢门是否在下一次启动后恢复原状摆放型建造玩法里面场景里新增的数百个物件需要被记住物品栏数值更新任务完成状态对关卡进行编辑并放置门、敌人出生点。如果你只做一个小游戏把位置和分数写入本地配置文件就足够但当你做玩法的“世界感”时还需要兼顾多种类型的数据。Godot 的 Dictionary 正是在这里起作用它可以存储数量、坐标、朝向、贴图类型和附加自定义信息保存时能一起序列化。同时它也提醒我们要注意存档的版本、校验和文件扩展。不建议做的事是不加处理地保存整个Node或者把所有数据塞进简单命名的一组变量里那样当场景节点结构一变存档就像废纸。类型化字典能推进这个问题的解法它让你把“每个对象的类别”和“对象的具体属性”界限拉到明确的位置。比如Dictionary[String, int]可以记录世界中某种物件的数量与对应 ID不需要一边写一边担心类型误用。3. 环境准备与项目基础设置请先确认引擎版本。我建议使用Godot 4.2 或更新版本本期示例会使用类型化字典写法。如果是 4.0/4.1你可以正常使用普通Dictionary只是在声明里去掉尖括号类型参数即可。新建一个工程主场景采用 Node2D 或 Node3D我们就把最核心的世界节点命名为World。为了集中展示我们会在场景里放两个测试节点一个Player角色角色类为CharacterBody2D或Node3D示例中为了便于观察用 Sprite一些需要被“移除”或“恢复”的内容物件比如 Wood、Stone 这样的静态节点。工程目录大致如下res:// ├── scenes/ │ └── main.tscn ├── scripts/ │ ├── world_save.gd │ └── right_click_remover.gd └── saves/ └── (运行时生成)4. 先想清楚Godot 的类型化字典代表什么4.1 普通字典与类型化字典的差异GDScript 里普通 Dictionary 可以随意写入各种类型var bag {} bag[item] sword bag[count] 3 bag[Vector2.ONE] 坐标这种自由度高但也容易在写多之后出现“键拼错但运行时才发现”的低级错误。类型化字典的价值是在声明阶段就把键类型与值类型定下来var item_count: Dictionary[String, int] {} item_count[wood] 5 item_count[stone] 3 # item_count[stone] abc # 类型错误不能写入 StringGodot 4.2 以后 GDScript 对字典的支持更强但这种尖括号写法本质上是“期望的类型标记”。运行时如果发生类型不符脚本会直接报错或抛出错误而不是在游戏逻辑里埋雷。4.2 类型化字典适合你展示的数据类型化字典的另一个实用场景是给你一组不同对象类型的字典。比如我们做世界存档每一种类型的物体需要记录它的 ID 和状态var placed_item: Dictionary[String, Dictionary] {} placed_item[gate] { position: Vector3(2, 0, 5), opened: false, }这比把物体参数写成一堆平行数组要清晰。在代码补全和语法检查方面类型标注也能让编辑器识别字典里面常见的层级结构帮助你减少手滑。4.3 什么时候不需要类型化字典如果只是三五个键的临时数据直接{}即可没必要为了类型而类型。世界保存时很多时候会遇到动态世界甚至无法保证每个存档物体都带上固定字段。我们需要保留字典的“面向未知字段”的弹性。更稳妥的做法是在代码层进行类型判定再写入在读取时逐一has检查再取值。5. 服务端世界存档设计从“存下所有节点”到“记录必要性”世界存档的核心并非把场景里每个对象状态原样写入文件而是把“负责恢复这个对象”所需的最小信息写入本地存档。比如一个门只需要位置旋转开合状态一个敌人只需要出生点位、当前血量、是否死亡。下面从一个简化的2D世界开始。它会记录以下东西玩家坐标位置玩家身上的金币数量当前世界里已放置物体的字典数组被移除物体的列表。保存类代码不能和具体某个节点死死绑定。应该由游戏里的管理节点汇总数据并调用存档接口。我们这样设计5.1 写入存档# world_save.gd extends Node # 保存文件位置 const SAVE_PATH : user://world_save.cfg func save_world(player_position: Vector2, player_gold: int, placed_objects: Array[Dictionary]) - void: var config : ConfigFile.new() # 分类存放玩家信息区块 config.set_value(player, position, player_position) config.set_value(player, gold, player_gold) # 世界物体区块整体放到一个数组 config.set_value(world, placed_objects, placed_objects) var err : config.save(SAVE_PATH) if err ! OK: push_error(世界存档失败错误码: %s % err) else: print(世界存档完成, SAVE_PATH)Godot 的ConfigFile会把数据以文本形式写进user://后续load也简洁直观。这里没有使用 JSON原因是 ConfigFile 自带内容分组而且天然支持Vector2、Array、Dictionary这些 Variant 类型。5.2 读取存档func load_world() - Dictionary: var config : ConfigFile.new() if not FileAccess.file_exists(SAVE_PATH): print(没有找到存档返回默认世界) return {} var err : config.load(SAVE_PATH) if err ! OK: push_error(读取世界存档失败错误码: %s % err) return {} var result : {} result[player_position] config.get_value(player, position, Vector2.ZERO) result[player_gold] config.get_value(player, gold, 0) result[placed_objects] config.get_value(world, placed_objects, []) return result默认参数的写法很有用。读不到某字段就直接给默认值而不是中断整个流程。存档本来就是一种“尽力而为”的恢复不允许因为旧存档少一个字段就把游戏卡死。6. 用一个“场景内物件列表”来配合移除和恢复仅仅保存“节点当前位置”并不能体现世界状态。如果场景中玩家建造了很多东西这些物件并不是由主场景直接“手写”在 scene 文件里的它们是在运行时动态生成的节点。需要一个运行时列表来统一追踪。我们可以这样设计所有被放置的世界物体都要注册进WorldManager。每次增加节点时把它对应的数据字典同时存一份保存时直接把整个列表写入存档。# 放在主场景里控制动态物体注册 extends Node2D var dynamic_objects: Array[Dictionary] [] func add_object(obj_type: String, pos: Vector2, extra_data: Dictionary {}) - void: var obj preload(res://scenes/world_object.tscn).instantiate() obj.position pos obj.obj_type obj_type obj.extra_data extra_data add_child(obj) dynamic_objects.append({ type: obj_type, position: pos, extra_data: extra_data, node_path: obj.get_path() })其中的node_path可以选择在节点加入场景树后从obj.get_path()拿到也可以只在运行时使用保存和读取处理时明确区分读取时更安全的方式是按照type和position重新生成对象而不是强行指回已经注销的旧节点。7. 右键移除工具从识别点击到节点清除7.1 右键菜单长什么样所谓右键移除工具未必是做一个系统编辑 UI更多的是让玩家在游戏里通过鼠标右键点击某个物件时把该物件从世界上删除并且把这个删除动作同步到存档数据里。常见实现方案对World场景做一个全局输入处理器监听鼠标右键按下根据鼠标位置获取被点击的物件通知世界管理节点移除该物件再次保存最新物件列表。如果直接用鼠标信号去判断点击对于 2D 游戏来说足够且效率高。使用Area2D也能做但会给场景增加大量子节点因而直接检测场景里的物件是否处于鼠标位置更偏重编程实现。下面使用我们维护的动态物件列表来做射线检测。7.2 基础版右键移除本示例思路界面上有一个 Sprite 和一个 CollisionShape2D也就是一个可点击世界物体点击鼠标右键会将这个物体从场景内移除并同步更新数据。# right_click_remover.gd extends Node2D export var object_group : world_object func _unhandled_input(event: InputEvent) - void: if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_RIGHT and event.pressed: remove_object_at(event.position) func remove_object_at(mouse_pos: Vector2) - void: var space : get_world_2d().direct_space_state var params : PhysicsPointQueryParameters2D.new() params.position mouse_pos params.collide_with_areas false params.collide_with_bodies true var result : space.intersect_point(params) if result.is_empty(): return var collider : result[0].collider if collider null: return var parent : collider.get_parent() if parent and parent.is_in_group(object_group): # 再把父子数据从 manager 的数组中删掉 get_parent().remove_world_object(parent)这里使用Mathvariantunhandled_input的一个好处是不干扰普通 UI Button 的点击。玩家点在 UI 面板上时按钮会先消费输入就不会出现“点背包里的东西却把场景模型删了”的问题。7.3 配合 WorldManager 的移除方法我们之前设计的动态节点列表还需要一个配套的移除接口func remove_world_object(node: Node) - void: var key_path: String str(node.get_path()) for i in range(dynamic_objects.size()): if dynamic_objects[i].get(node_path) key_path: dynamic_objects.remove_at(i) break node.queue_free() save_after_change()如果只需要把当前场景里全部动态物体快速清除可以反向遍历func clear_all_world_objects() - void: for child in get_children(): if child.is_in_group(world_object): child.queue_free() dynamic_objects.clear()8. 用类型化字典与实际数据一起过滤在真正对物体使用右键移除时其实我们还需要考虑“能不能删”“删了之后存档读取对不对”。此时Dict[String, Variant]可以成为统一的物体描述协议。比如新建场景里放置一个wood_stack物体组右侧的数据记录可设计如下func describe_object(node: Node) - Dictionary: var desc: Dictionary[String, Variant] {} desc[type] node.obj_type desc[position] node.position desc[can_remove] node.can_player_remove # bool 类型数据 return desc func is_removable(desc: Dictionary) - bool: if desc.has(can_remove): return desc[can_remove] is bool and desc[can_remove] return false这样存档时遍历场景里的动态对象把可移除和不可移除两种节点分开存放在加载时对不可移除对象正常生成对可移除对象只生成不可移除的初始备份。“世界存档”不是让玩家钻空子只要把上季度资源刷新出来反而是确保每一次之后玩家能回到合理的世界状态。如果用整数状态做判断可以如下实现字典var object_state: Dictionary[String, int] {} object_state[gate] GlobalState.CLOSED object_state[chest] GlobalState.LOCKED类型化字典是给“状态机上移”提供的类型安全。在读取时像“门是否开启”这样的字段永远只应与整数比较其他如null或字符串都应可以被直接忽略。9. 存档恢复的完整流程从文件回到世界前面我们保存了dynamic_objects现在我们把它恢复到场景中。使用工厂模式或match type能处理多种类型func restore_world_from_save(save_data: Dictionary) - void: var player_pos: Vector2 save_data.get(player_position, Vector2.ZERO) var player_gold: int save_data.get(player_gold, 0) # 清空当前动态物件 clear_all_world_objects() # 重建 var objects: Array save_data.get(placed_objects, []) for object_data in objects: if typeof(object_data) ! TYPE_DICTIONARY: continue var obj_type: String object_data.get(type, unknown) var pos: Vector2 object_data.get(position, Vector2.ZERO) var extra: Dictionary object_data.get(extra_data, {}) add_object(obj_type, pos, extra) # 更新玩家位置 $Player.position player_pos $Player.gold player_gold第一次循环前做typeof判断似乎是多余的却很像一个正式项目会把不稳态数据弹走。存档可能是你之前某个版本里生成的不同结构也可能被玩家手动改过看到一条无法解析的Array/Object就直接跳过比崩掉好。10. 右键移除工具与存档的联动问题如果只是做到“右键成功删掉了节点”那么下次重开游戏时节点又会出现——原因是你并没有把“移除了什么”记录到世界存档中。右键移除工具正确工作之后还需要把“移除节点”变成一种“事件”回调而不是闷不吭声调用queue_free然后结束。简单来说你需要记录一个“被移除列表”并定期自动保存。这样当玩家重新进入世界时同样坐标的物件就不会再生。10.1 记录并持久化被移除列表var removed_objects: Array[Dictionary] [] func remove_world_object(node: Node) - void: var key_path: String str(node.get_path()) for i in range(dynamic_objects.size()): if dynamic_objects[i].get(node_path) key_path: var removed_data : dynamic_objects[i].duplicate() removed_data[removed_at] Time.get_unix_time_from_system() removed_data[node_path] key_path removed_objects.append(removed_data) dynamic_objects.remove_at(i) break node.queue_free() save_after_change()保存时把removed_objects一起写进ConfigFile。恢复时var removed_ids: Array save_data.get(world, removed_objects, []) for removed in removed_ids: if typeof(removed) TYPE_DICTIONARY: var removed_key : str(removed.get(node_path, )) removed_cache[removed_key] true当add_object尝试重建一个原点obj时如果发现对应的node_path已在被移除列表里就跳过生成。这种做法的好处是防抖不会因为玩家反复移除添加同一个静态出生点而越积越多。一个更简单实用的选择把路径替换成“带坐标和类型生成的唯一 key”比如func object_key(type: String, pos: Vector2) - String: return %s_%d_%d % [type, int(pos.x), int(pos.y)]因为节点的路径在下次启动时很可能变化不合适跨会话存储。存档的恢复动作要基于业务关键字。11. 性能与资源占用观察点本节不需要高深分析。但有几个容易忽略的卡顿点值得你测试前关注场景里如果只有几十个物体直接遍历dynamic_objects来同步删除是没问题的如果物体数量达到几千个那么每次鼠标单击都遍历一次数组就不划算一个高效的改进是给每个物体在注册时记录id建立一个Dictionary[int, int]映射索引查找更快queue_free()并不会立刻释放节点它会在当前帧结束前执行。所以如果你紧跟代码马上访问它的is_inside_tree()仍可能返回 true更谨慎的应使用if is_instance_valid(node)进行判断save_after_change()只是在remove_world_object末尾调用并不存在长期性能影响如果玩家会频繁操作可把保存间隔收合并成“最后一帧”、退出前一次也可。12. 右键操作中常见的输入冲突如果你在测试右键移除工具时发现右键点击却没有效果有时点不到物体点物体但加载存档后还是出现旧物体。这些问题与输入、碰撞层和存档缓存都有关。排查思路表问题现象可能原因解决方案右键无反应_unhandled_input被 UI 或_input拦截在_input里检测event.is_action_pressed(右键)并检查 UI 是否消费点击不到物体物体没有 CollisionShape 或 CollisionLayer 不对确认射线查询参数打开该层右键同时引发多节点被删intersect_point返回多个结果循环移除只处理第一个结果或按距离取最近重启后物体恢复没有写入保存数据或者加载在恢复前执行在restore_world_from_save初始化后严格按“标记删除列表”过滤字典没有正确类型4.2 以下旧项目不支持泛型字典加类型报错弱化声明直接使用Dictionary改写普通字典并保留运行判断开关调试可视化在 Godot 4 编辑器里可以通过调试→可见的碰撞形状来观察每个对象是否都有碰撞体比较方便检查射线是否真的瞄准到了对象。如果右键针对的是 3D 对象我们需要把它从PhysicsPointQueryParameters2D改成PhysicsRayQueryParameters3D思路一致但碰撞层与平面坐标的处理会增加成本。13. 给世界存档加版本号和迁移字段开始写世界存档后你现在就要有意识地预判以后你的游戏新增了物体属性标题里常见的“世界存档”可能不够——你需要存档版本。简单实现func save_world(...) - void: config.set_value(save_info, version, 1) ...读取后立刻做版本判断var save_version: int config.get_value(save_info, version, 1) if save_version 1: # 旧版本无对应字段需要迁移或补齐默认值 pass存档迁移的必要性源于“世界里的结构”——每个存档之间可能存在兼容问题比如某个物体原来保留了texture_name后来被改成了item_id你需要写迁移模块在载入时把texture_name转为item_id然后把版本号提升。14. 右键移除工具再升级改造成可复用组件右键移除工具早期可以直接写在玩家主角脚本上但当它和被点对象的世界逻辑纠缠多时强烈建议独立抽取成一个组件一个RemovalController节点负责监听输入、做射线检测然后发出信号世界物体自己不直接处理“被移除”而是监听自身需要的事件所有对存档数据的增删都由WorldManager或类似的管理者统一处理。这段信号流能够很好地解耦# removal_controller.gd signal request_remove_object(node: Node) func _unhandled_input(event: InputEvent) - void: if event is InputEventMouseButton \ and event.button_index MOUSE_BUTTON_RIGHT \ and event.pressed: request_remove_object.emit(_get_object_at(event.position))主场景负责连接func _ready() - void: $RemovalController.request_remove_object.connect(_on_request_remove_object) func _on_request_remove_object(node: Node) - void: if node null or not is_instance_valid(node): return if node is WorldObject and node.is_removable(): world_manager.remove_world_object(node)这样右键移除工具之后也可以复用到一个单独的房间 Editor 相关工具中不仅仅是玩家操作。15. 编辑器工具里的“右键配置下拉菜单”我们标题里的“右键移除工具”也可能是指 Godot 编辑器中快速移除节点的自定义工具因为“世界存档 字典 右键工具”放在一起通常也被用来做内部编辑器功能。如果出现把多个工具组合起来使用的情况建议用 Godot 编辑器的EditorPlugin加EditorContextMenuPlugin能够挂到场景树面板的右键菜单里Godot 4.3 之后的 Extensions 更健壮。但在一般运行向游戏场景中不需要依赖它。使用我们前面写的“输入检测 射线对象 移除并更新数据”的组件就是最基础且体量最轻的选择也能保证玩家在正式游玩时可用而不是只有编辑器里能删。16. 功能的实际操作建议16.1 第一次做世界存档先保持最简不要立刻把加载、删除、增补按钮全部做出来。建议第一版先跑通整个最简闭环玩家移动到一个位置按快捷键保存修改玩家位置或删掉一个物体读取存档节点和玩家坐标恢复原位置。跑通后再扩展键值列表、二进制、加密等。16.2 注意user://路径Godot 的不同平台user://路径不同Windows 下一般在AppData\Roaming\Godot\app_userdata\项目名\Android 下在应用私有目录。应用提交版本测试时可以打印一条消息确认地址print(ProjectSettings.globalize_path(SAVE_PATH))16.3 类型化字典加载之后别忘转换类型化字典能保证编译期一部分安全但当你从ConfigFile读取数据时读出的Variant本身不一定是正确的运行时类型。所以恢复时建议依然加判断var raw_pos object_data.get(position, Vector2.ZERO) if raw_pos is Vector2: actual_pos raw_pos else: actual_pos Vector2.ZERO这一点比类型标注更重要。16.4 多存档槽位改造如果你紧接着想要实现多存档槽位把SAVE_PATH里再带一个槽位编号即可。例如const SAVE_DIR : user://saves/slot_%d/或使用单个文件内分组config.set_value(slot_0/player, position, player_pos)16.5 数据真正保存前做一次自检保存前把要写进存档的整个结构打印出来测试如print(dynamic_objects)能直接看出哪些对象的字典缺字段、数组是否多套了一遍。17. 容易错的知识点汇总把常见细节单独拿出来列是因为它们比“不会写关键代码”更容易坑人Dictionary的 key 不一定是字符串。在一个不经过转换的Dictionary中它可以为任何 Variant 类型。用node.get_instance_id()作为 key 也是常见技巧保存后的Array里可能藏着Node或Object这类对象无法直接序列化。用Dictionary只保存基础类型的数据是正确方向场景对象通过queue_free()删除后旧引用暂时不可靠建议调用后再做一次有效性判断ConfigFile.save出错时错误码具体值可查文档。读取失败后游戏应优先回到默认世界而不是直接崩溃如果你使用类型化字典不同版本 Godot 之间的兼容性有差异如果你把代码当模板发给别人对方是 Godot 4.1 则需要去掉某些通用声明。Godot 的类型化字典和普通字典的实际运行性能差异通常不大GDScript 写的逻辑瓶颈往往在更多的节点管理或动态分配上。类型标注主要让代码变清晰、变量用途明确、编辑器早期报错也就是工程体验层面的收益。18. 后一步批量移除、框选与热更新当你实现了单次右键移除下一步可以很容易扩展为批量移除鼠标按住右键拖拽一个矩形范围批量射线检测或直接遍历节点是否存在在矩形范围内对每个节点发起remove_world_object移除操作要放到两帧循环中执行或者使用_draw画一个选择框。根据被移除对象的类型可以把“存档数据删除”和“节点删除”用信号拆开这样可逆、可撤销的实现也能轻松地插入每次删除前记录撤销栈在下次鼠标中键时恢复。如果愿意继续成长到这么细的粒度存档版本和撤销备份就需要更具体的运行时。本期内容做的其实就是游戏开发里玩家往往感觉不到、却是程序里必须扎实的一层结构。先把世界能存下来再把运行时对象怎么记录、如何用右键移除统一起来之后你要做重进恢复、建造系统、关卡编辑也不需要变换思路。直接打开你的 Godot 工程先建一个会动的 Player 和一个可以点击删除的箱子然后重复保存、改动、读取三个步骤就能完成最轻量的世界存档验证流程了。