ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Godot 4.x 世界存档与右键移除:类型化字典 + JSON 实现

Godot 4.x 世界存档与右键移除:类型化字典 + JSON 实现 最近在做一个类似“我的世界”风格的 Godot 小项目做到“世界数据怎么存、怎么删、怎么还原”的时候发现这三个问题其实是连在一起的第一世界方块数据如果全用普通字典存代码写多了之后特别容易在键值类型上出错第二如果想要支持右键移除方块那鼠标拾取、数据同步、场景清理必须一起处理第三存档如果不设计好格式后期加版本兼容会非常难受。这一篇就围绕“世界存档 类型化字典 右键移除工具”三个点展开给出可直接落地的 Godot 4.x 实现思路和完整 GDScript 代码。无论你是打算做体素世界、2D 地图编辑器还是单纯想在存档系统里用更安全的字典结构这篇都可以直接参考。先给结论这个方案的核心是把“世界状态”抽成一份和场景节点解耦的数据结构运行时用类型化字典维护交互层只负责修改数据然后再由统一的存档组件负责落盘和恢复。用这种方式写出来的功能不会出现“移除了方块但存档里还在”、“读档后场景和字典对不上”这类经典问题。1. 核心能力速览能力项说明项目类型Godot 游戏开发教程涉及世界存档、数据结构和鼠标交互目标引擎Godot 4.x类型化字典推荐 4.4 及以上主要功能方块数据管理、右键移除方块、世界存档与读档数据结构类型化字典Dictionary[Vector3i, int]存档方式JSON 文本存档存放位置为用户目录user://交互方式鼠标射线拾取左键放置 / 右键移除可通过配置切换批量能力支持按节点统一释放、统一导出、统一加载适合场景体素/方块世界、网格类地图编辑器、管理类小游戏存档这个方案重点不是 GPU 或显存而是“数据结构和场景管理是否一致”。Godot 是轻量级引擎CPU 逻辑和节点数量才是主要关注点下面会具体讲。2. 适用场景与使用边界这一篇的技术可以用于类似 Minecraft 的方块世界或者 3D 网格建造玩法2D TileMap 的编辑器式操作比如“右键移除瓦片”需要把任意类型的键值结构保存到 JSON 的自定义存档系统需要把“运行时操作”和“持久化数据”解耦的项目。不适合的场景也要说清楚如果你做的是超大地图、千万级方块那 JSON 全量存盘不是最优解应该按区块分文件或使用二进制存档如果你需要高频实时同步比如多人联机服务器那这种本地文件式存档就不能直接用类型化字典虽好但底层运行时依然是一个 Dictionary超大数据量下要自己做好分块管理。合规层面也提醒一句所有教程代码请勿用于未授权网络攻击、破坏他人服务器存档、扒取其他游戏地图数据等场景。如果项目涉及用户生成内容、玩家地图上传分享需要明确存档来源与版权归属。3. 环境准备与前置条件建议先确认环境检查项推荐值Godot 版本Godot 4.4 及以上类型化字典完整支持操作系统Windows / macOS / Linux 均可是否需要 GPU不需要基本逻辑场景 CPU 足够是否需要下载模型不需要开发语言GDScript如果你想用类型化字典语法比如Dictionary[Vector3i, int]那编译器会直接做类型校验前提是编辑器版本支持。Godot 4.4 之前不是不能用字典只是没有“类型化”语法。过旧的版本建议先升级。在没有网络的情况下Godot 标准版自带 GDScript 和 3D 场景支持。项目不需要额外插件直接用内置节点完成。4. 场景结构与最小项目搭建我们先搭一个最简 3D 场景用来跑通“右键移除 世界存档”这条链路。4.1 项目目录结构res:// ├── project.godot ├── scenes/ │ ├── main.tscn │ └── block.tscn └── scripts/ ├── world.gd └── block.gd4.2 主场景 Main 的节点树Main (Node3D) ├── Camera3D ├── Sun (DirectionalLight3D) ├── World (Node3D) │ ├── Blocks (Node3D) │ └── world.gd └── UI (CanvasLayer) └── Control └── 说明 Label世界方块统一挂到World/Blocks下面。这样移除方块时只需要在Blocks下找对应节点queue_free()即可。Camera3D需要放在合适位置方便看到地面方块并发出射线。简单起见可以把相机放在(8, 8, 8)看向原点。4.3 方块场景 Blockblock.tscn的根节点可以是StaticBody3DBlock (StaticBody3D) ├── MeshInstance3D └── CollisionShape3D在block.gd里记录自己的格子坐标class_name Block extends StaticBody3D # 该方块在世界坐标系中的格子坐标 var cell: Vector3i这样世界脚本只要拿到被点击方块的Block节点就能知道它的坐标进而操作字典。5. 用类型化字典管理方块数据5.1 为什么用类型化字典Godot 4.4 开始支持类型化字典。写法是var world_blocks: Dictionary[Vector3i, int] {}这样编译器知道key 必须是Vector3ivalue 必须是int普通字典写多了容易出现类似block_id data[id]后类型不确定的问题。类型化字典能在编码阶段帮你拦截一部分错误阅读代码时也非常直观。但要注意类型化字典的 value 不一定是简单int也可以是对象、数组、嵌套字典。如果是自定义对象需要确认它继承自RefCounted或Object并且随手初始化避免 null 传递。5.2 在 World 中创建字典并放置方块world.gd核心代码extends Node3D const BLOCK_SCENE : preload(res://scenes/block.tscn) # 整张地图的方块数据格子坐标 - 方块 ID var world_blocks: Dictionary[Vector3i, int] {}在地图初始化时我们放置一块地面。这里用两层方块示意其中 ID 可以设计为 1 表示草方块、2 表示泥土方块。然后调用一个方法统一生成场景节点并写入字典。func _ready() - void: _init_world() _build_block_nodes() # 先往数据字典里写入一些初始方块 func _init_world() - void: for x in range(-3, 4): for z in range(-3, 4): var top : Vector3i(x, 0, z) world_blocks[top] 1这里先只写数据。节点同步再单独做。5.3 根据字典生成场景节点因为世界数据在字典而场景显示在节点树两者必须保持同步。func _build_block_nodes() - void: var blocks_container : $Blocks for cell: Vector3i in world_blocks: var block_id: int world_blocks[cell] _spawn_block(cell, block_id) func _spawn_block(cell: Vector3i, block_id: int) - void: var block : BLOCK_SCENE.instantiate() as Block blocks_container.add_child(block) block.cell cell block.position Vector3(cell) # 这里可以根据 block_id 设置模型、材质、颜色这个流程的顺序很重要先改字典再改场景或者先改场景再改字典都可以但一定要保持事务性。不要在场景里直接生成方块但不写字典也不要在字典里保存数据但不生成节点。5.4 遍历类型化字典在 Godot 4.4 的类型化字典中for 循环也可以直接标注变量类型。上面的_build_block_nodes已经用了for cell: Vector3i in world_blocks:这比for cell in world_blocks:更清晰也方便编译器帮助你检查后续类型调用。5.5 注意事项类型化字典如果传入错误类型运行时可能出现类似Invalid type in function set in base Dictionary.遇到这种问题先检查写入字典的变量类型是否和声明一致。特别是在读 JSON 存档时从 JSON 里解析出来的 key 默认是String或float不能直接塞进Dictionary[Vector3i, int]需要做一次显式转换。6. 实现右键移除工具6.1 鼠标操作逻辑我们使用鼠标射线检测来拾取当前指向的方块。func _unhandled_input(event: InputEvent) - void: if event is InputEventMouseButton and event.pressed: if event.button_index MOUSE_BUTTON_RIGHT: _try_remove_block() elif event.button_index MOUSE_BUTTON_LEFT: _try_place_block()这里的思路是左键放置方块逻辑里可以先简单固定放一个指定方块 ID。右键移除当前指向的方块。6.2 从鼠标位置发射射线func _get_mouse_ray_origin_and_dir() - Array: var camera : get_viewport().get_camera_3d() var mouse_pos : get_viewport().get_mouse_position() var origin : camera.project_ray_origin(mouse_pos) var dir : camera.project_ray_normal(mouse_pos) return [origin, dir] func _get_raycast_hit_block() - Block: var ray_data : _get_mouse_ray_origin_and_dir() var origin: Vector3 ray_data[0] var dir: Vector3 ray_data[1] var query : PhysicsRayQueryParameters3D.create(origin, origin dir * 200.0) var result : get_world_3d().direct_space_state.intersect_ray(query) if result.is_empty(): return null var collider : result.get(collider) if collider is Block: return collider as Block return null射线碰撞到的对象如果是一个Block那我们直接拿到它的cell坐标。6.3 右键移除的实际同步过程func _try_remove_block() - void: var block : _get_raycast_hit_block() if block null: return remove_block_at(block.cell) func remove_block_at(cell: Vector3i) - void: if not world_blocks.has(cell): return # 1. 从数据字典中移除 world_blocks.erase(cell) # 2. 从节点树中移除 var blocks_container : $Blocks for child in blocks_container.get_children(): var block : child as Block if block ! null and block.cell cell: block.queue_free()这里不建议直接遍历所有方块删除而是一个一个小范围内查找。如果地图很大可以考虑建立一个key - Block节点索引字典提高查找效率。在“移除工具”这个需求上只删除数据不够只移除节点也不够。必须两边同步删否则重新读档时会出现行为不一致。6.4 单独维护节点索引字典的改进当地图方块数量变多时每次都扫描所有Blocks子节点会变慢。更工程化的做法是加一个节点索引var block_nodes: Dictionary[Vector3i, Block] {}生成时写入func _spawn_block(cell: Vector3i, block_id: int) - void: var block : BLOCK_SCENE.instantiate() as Block $Blocks.add_child(block) block.cell cell block.position Vector3(cell) block_nodes[cell] block移除时直接通过索引删除func remove_block_at(cell: Vector3i) - void: if not world_blocks.has(cell): return world_blocks.erase(cell) if block_nodes.has(cell): var block : block_nodes[cell] block_nodes.erase(cell) block.queue_free()这样右键移除操作就不再依赖遍历时间复杂度从 O(n) 降到 O(1)。虽然小地图阶段无所谓但这是一个非常好的习惯后面做大批量方块编辑时能省很多事。6.5 左键占位放置逻辑右键移除工具对应的配套功能通常是左键放置。这里也提供一个最简版本func _try_place_block() - void: var camera : get_viewport().get_camera_3d() var mouse_pos : get_viewport().get_mouse_position() var origin : camera.project_ray_origin(mouse_pos) var dir : camera.project_ray_normal(mouse_pos) var query : PhysicsRayQueryParameters3D.create(origin, origin dir * 200.0) var result : get_world_3d().direct_space_state.intersect_ray(query) if result.is_empty(): return var hit_cell : Vector3i.ZERO if result.get(collider) is Block: hit_cell (result.get(collider) as Block).cell else: # 如果点到了地形或其他对象就按整个碰撞位置换算 var hit_pos : result.get(position) as Vector3 hit_cell _world_to_cell(hit_pos) var target_cell : hit_cell Vector3i.UP if world_blocks.has(target_cell): return world_blocks[target_cell] 1 _spawn_block(target_cell, 1)这里的_world_to_cell可以根据你的方块大小来写func _world_to_cell(world_pos: Vector3) - Vector3i: return Vector3i( roundi(world_pos.x), roundi(world_pos.y), roundi(world_pos.z) )7. 实现世界存档与读档7.1 存档格式设计世界数据本质上是很多“坐标 - 方块 ID”的组合。JSON 文本存档的好处是可读、可调试、跨 Godot 小版本兼容性好。我们设计的存档文件结构{ format_version: 1, name: world_001, blocks: { -3,0,-3: 1, -2,0,-3: 1 } }为什么 key 不直接用数组[x, y, z]因为 JSON 对象的 key 只能是字符串如果转成[x, y, z]解析会麻烦一点。用x,y,z这种字符串既简单又直观。为什么方块 ID 要用 int因为 int 占用小、易于对照配置表。7.2 保存到 JSONconst SAVE_PATH : user://world_save.json func save_world() - void: var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file null: push_error(无法打开存档文件: %s % SAVE_PATH) return var data : { format_version: 1, name: world_001, blocks: _export_blocks() } file.store_string(JSON.stringify(data, \t)) file.close() print(世界存档完成%s % SAVE_PATH)导出字典时需要把Vector3i转成字符串 keyfunc _export_blocks() - Dictionary: var exported : {} for cell: Vector3i in world_blocks: var key : %d,%d,%d % [cell.x, cell.y, cell.z] exported[key] world_blocks[cell] return exported这里有一个细节内部的world_blocks是类型化字典但exported用普通字典即可。因为 JSON 里面Vector3i不能作为 key导出后的普通字典更方便后续序列化。7.3 读档并恢复场景读档时要做的步骤打开 JSON 文件解析 JSON检查格式版本清空当前场景节点清空当前world_blocks遍历 JSON 中的blocks把字符串坐标转回Vector3i写入world_blocks重新生成所有 Block 节点。func load_world() - void: if not FileAccess.file_exists(SAVE_PATH): print(存档不存在跳过读档) return var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: push_error(无法打开存档文件) return var text : file.get_as_text() file.close() var json : JSON.new() var err : json.parse(text) if err ! OK: push_error(JSON 解析失败: %s % json.get_error_message()) return var data: Dictionary json.data var format_version: int data.get(format_version, 0) if format_version ! 1: push_warning(存档版本不支持当前支持版本: 1) return _clear_world_nodes() world_blocks.clear() var blocks: Dictionary data.get(blocks, {}) for key: String in blocks: var parts : key.split(,) if parts.size() ! 3: continue var cell : Vector3i( int(parts[0]), int(parts[1]), int(parts[2]) ) var block_id: int int(blocks[key]) world_blocks[cell] block_id _build_block_nodes() print(世界读档完成方块数量%d % world_blocks.size())清空节点的方法func _clear_world_nodes() - void: var blocks_container : $Blocks for child in blocks_container.get_children(): child.queue_free() block_nodes.clear()注意queue_free()并不是立刻从节点树移除它是延迟到帧结束才清理。如果读档后立刻调用_build_block_nodes()旧方块节点可能还在容器里。这在当前小场景下影响不大但严谨一点可以清理后先await get_tree().process_frame再生成新节点或者直接用child.free()立即可销毁。简单项目建议这样处理func _clear_world_nodes() - void: var blocks_container : $Blocks for child in blocks_container.get_children(): blocks_container.remove_child(child) child.queue_free() block_nodes.clear()8. 输入快捷键与存档触发实际操作中我们不会每次都去 UI 点按钮。可以在_process或_unhandled_input中注册快捷键func _process(_delta: float) - void: if Input.is_key_pressed(KEY_F5): save_world() if Input.is_key_pressed(KEY_F9): load_world()不过这种写法有重复触发风险。更严谨的写法是监听按键刚刚按下的事件func _unhandled_input(event: InputEvent) - void: if event is InputEventKey and event.pressed and not event.echo: match event.keycode: KEY_F5: save_world() KEY_F9: load_world()如果没有在这个世界脚本里挂点选逻辑也可以为项目注册定义 Input Map 事件让操作更符合正式游戏习惯这要看项目按键布局来决定。9. 功能测试与效果验证这一部分的目的是验证“世界存档”和“右键移除工具”确实协同工作。9.1 测试用例清单测试项操作预期结果常见失败现象初始世界生成运行场景地面方块正常显示方块缺失或重叠类型字典检查运行场景输出字典数量与节点数量一致编译警告或运行错误右键移除方块鼠标右键点击方块方块消失再次点击没有反应方块仍在但字典已删左键放置方块鼠标左键点击空位上方新增方块没找到目标坐标方块在旧位置出现保存世界按 F5控制台输出存档完成无法写入 user 目录读档恢复移除几个方块后按 F9方块恢复初始状态旧节点仍在出现双份移除后存档再读档右键移除按下 F5再按 F9方块保持被移除状态存档后又被旧数据覆盖9.2 自动验证脚本示例如果不想手动点击也可以做一个简单的自动化验证func _debug_auto_test() - void: # 保存当前状态 var before_count : world_blocks.size() # 移除第一个方块 var first_cell: Vector3i world_blocks.keys()[0] as Vector3i remove_block_at(first_cell) # 保存再读档 save_world() load_world() # 检查移除是否被正确保存 if world_blocks.has(first_cell): print(测试失败方块仍然存在) else: print(测试通过移除操作已持久化) # 清理 _clear_world_nodes() world_blocks.clear()在实际项目中不建议把这种测试函数长期留在发布版里可在编辑器环境下调用。9.3 观察重点手动测试时重点观察移除方块时是否所有相关节点都消失了保存时文件是否有生成读档后场景是否和世界字典一致连续读档多次是否出现节点重复有没有空引用错误。10. 资源占用与性能观察虽然 Godot 这种小体量项目不涉及显存但资源占用依然值得关注。10.1 内存与节点占用世界方块的数量直接决定节点数量。方块数量观察方式说明几百个几乎没有压力常规运行几千个注意物理体数量不建议每个方块都做StaticBody3D物理碰撞几万个建议改用 Mesh 合并否则场景树和管理开销会逐步上升如果你的项目不止测试场景而是想支持大型地图建议不要为一个方块建立一个StaticBody3D。可以用以下思路优化小块区域合并成一个MeshInstance3D只是鼠标拾取时再单独计算是哪一块或只对玩家附近的区域生成物理体空方块不生成节点只把有数据、有碰撞需求的方块保留节点。10.2 保存与读取开销JSON 全量保存适合中小型世界。一个 1000 方块的地图JSON 文件大小通常也就几十 KB 到几百 KB读起来非常快。但如果地图膨胀到百万级方块就要考虑分区块文件、二进制格式、后台线程写盘等方式。特别是在移动端全量 JSON 序列化可能造成明显的卡顿。10.3 字典操作的耗时观察可以在循环里简单测量耗时var start : Time.get_ticks_usec() # 执行批量保存或批量读取 var elapsed : Time.get_ticks_usec() - start print(耗时: %d us % elapsed)观察原则是保存/读档如果在主循环里出现明显卡顿就要改成增量保存或分帧处理遍历字典做节点同步时能用索引字典就不反复 get_child不要在_process里每帧遍历所有方块。11. 常见问题与排查方法问题现象可能原因排查方式解决方案类型化字典语法报错项目版本低于 Godot 4.4查看编辑器版本升级引擎或改成普通 DictionaryVector3i 无法作为 JSON 对象 keyJSON 对象 key 只支持字符串打印导出结果转成x,y,z字符串再存档方块移除后读档又出现移除后没有保存确认是否执行 save_world按 F5 手动保存或增加自动保存方块节点重复出现读档前没有彻底清理旧节点观察场景树用 remove_child queue_free并清空 block_nodes点击右键没有反应射线没有命中 Block检查碰撞层确认 Block 有 CollisionShape3D左键放置位置不对射线命中的面换算错误打印 hit_position根据命中法线计算相邻格子JSON 解析失败文件被手动改坏打印 json.get_error_message用代码重新保存或做兜底 check移除大量方块时卡顿遍历所有节点查看节点数量使用 block_nodes 索引字典文件保存失败user 路径不可写打印 FileAccess 错误改路径或检查磁盘权限运行旧版本工程打开报错新版本引擎创建的类型不兼容查看日志备份后重新导入12. 最佳实践与合规提醒从这套代码继续往深处做有几点建议可以立刻用上12.1 数据与表现分离保证世界逻辑的核心状态始终在world_blocks字典里方块节点只是它的“可视化投影”。也就是说任何时候要对世界做操作先改字典再同步节点任何从 JSON 恢复、网络同步修改状态的逻辑也只改字典然后通知场景重建或局部刷新。这样后续增加“撤销/重做”“区块卸载”“联机同步”都会方便很多。12.2 存档文件增加版本字段当前代码里面用format_version标识版本。哪怕现在只有一个文件也建议保存这个字段。以后增加新的方块类型、加入天气数据、加入 NPC 坐标时没有版本号会很难迁移。12.3 批量操作加上事务化方法比如你需要“一键清空地图”“批量生成山体”不要一个格子一个格子调外部接口。可以设计成func begin_batch_modify() - void: pass func end_batch_modify() - void: rebuild_all_block_nodes()批处理结束后再重建场景节点性能会好很多。12.4 不要加载未知来源存档如果你提供了“玩家存档导入”功能一定要校验存档文件格式和数据结构不能盲目信任外部输入。恶意 JSON 存档可能包含超大数组、异常类型轻则卡死重则利用漏洞造成数据污染。12.5 版权与素材合规如果最终做成品游戏发布注意方块贴图、音效、字体素材确认是否有商用授权如果地图允许玩家上传分享需要引导玩家遵守内容规范如果项目参考了别人的世界生成算法确认开源协议范围不要直接解包、搬运其他商业游戏的地图数据和美术资源。13. 总结与下一步这一篇的核心并不是“学会调用一个函数”而是建立一套可扩展的世界数据管理思路用类型化字典统一维护“格子坐标 - 方块 ID”用节点表示方块的可视化对象用节点索引字典加速移除用 JSON 做持久化存档读档时先清空节点再重建节点保证双方一致。你动手时优先验证的是“右键移除方块后保存、再读档方块是否保持移除状态”。这是整套方案最关键的闭环。如果你想把这套代码继续扩大有几个方向可以顺路做引入区块概念以固定区域为粒度管理数据与节点把存档从 JSON 换成二进制并做压缩给字典中的方块 ID 建立配置文件表统一管理碰撞类型、贴图和生命值加入多存档位切换和自动备份机制把输入交互从单机鼠标改成触屏/手柄兼容。另外如果你还在学 Godot 基础比如 GDScript 语法、场景树、物理射线不妨把这个小项目拆开做先做一条线测试再慢慢把“左键放置、右键移除、F5 保存、F9 读档”四个操作分别验证好。代码小逻辑清晰后面接任何玩法都能直接复用这套底座。建议先收藏这篇等真正动手写世界存档时再回来对照。
返回列表