news 2026/9/4 21:53:19

Godot建造游戏开发:类型化字典、世界存档与右键移除实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Godot建造游戏开发:类型化字典、世界存档与右键移除实现

在 Godot 的建造类游戏开发中,“摆放方块”只是第一步,真正让世界变得可玩的是三件事:世界能被保存、数据能被高效管理、方块能被再次移除。这一篇是系列教程的 Ep 2.5 补充篇,我把它定位成一个很实用的“功能缝合包”:在已有的 Godot 项目中加入世界存档,用类型化字典来管理方块数据,同时实现一个鼠标右键移除方块的工具。

无论你是正在做类似 Minecraft 的体素世界,还是做了一个基于网格的建造小游戏,这套流程都值得完整过一遍。即使暂时不太理解类型化字典的底层原理,也能照做跑通,然后在实战里慢慢体会它的好处。

1. 背景与核心概念

1.1 为什么需要“世界存档”

很多新手在完成了“左键放置方块”之后,会面临一个尴尬情况:游戏看似能玩了,但只要关闭窗口再打开,所有玩家辛辛苦苦建造的内容全部消失。一个没有存档的游戏,就像记事本不能保存文件一样,只能算一个演示 Demo。

世界存档的本质,就是把游戏运行时内存中的状态,转换成一种可以在磁盘上长期保存的数据格式。我们需要从中选出真正需要保存的内容。

在体素或网格建造类游戏里,世界状态的灵魂是每个格子上的方块类型。假设一个世界只有 3 个方块,位置分别是(0,1,0)(1,2,3)(4,0,2),那么存档只需要回答三个问题:

  • 有几个方块?
  • 分别站在哪些网格坐标上?
  • 每个坐标上的方块是什么类型?

把这些信息存到文件里,游戏启动时再读取文件、重新创建方块节点,世界就恢复了。

1.2 类型化字典:给字典加上“约束”

Godot 的 GDScript 里有三种最常用的集合类:数组、字典、信号。字典相当于一个“码表”,左边是键,右边是值,例如:

  • 键:坐标Vector3i(1, 2, 3)
  • 值:方块类型1

由于每个方块都需要记录“位置 -> 类型”的映射关系,字典几乎是建造游戏里最合适的数据结构。

而“类型化字典”是 Godot 4.x 提供的一种增强写法,它在字典的基础上增加了类型的限制。普通字典就像一个“什么都能装的箱子”,而类型化字典则像带隔断的收纳盒:你事先规定了键必须是哪种类型、值必须是哪种类型,放错了就会报错或无法通过编辑器的检查。

用代码表示,这就是一个类型化字典:

var blocks: Dictionary[Vector3i, int] = {}

它表示blocks的键必须是Vector3i类型,值必须是int类型,不能混淆。虽然看起来只是多了个尖括号,但在项目变大后能减少大量低级错误。

1.3 右键移除:让对象可以修改

一个只能放置不能移除的工具箱是不完整的。玩家摆错位置怎么办?想改造建筑怎么办?我们需要一个“拆除”操作。

放在第一人称建造类游戏里,最自然的交互就是:鼠标左键放置方块,鼠标右键移除方块。移除操作要做的其实也不复杂:

  • 从相机视线方向发射一条射线。
  • 检测射线是否撞到了某个方块。
  • 如果撞到了,就从世界中删除该方块节点,并从字典数据里删掉对应记录。

这里有一个容易被忽略的重点:场景里的方块节点只是一层“表现”,而真正决定世界状态的是内存中的字典数据。所以右键移除不只是把场景里的节点删掉,还要同步删除字典中的记录。如果不删字典,下次保存存档又会出现已经拆除的方块。

1.4 一个简单的数据关系图

为了便于理解,我们可以用文字把整个体系的关系画出来:

世界容器 WorldController ├── 方块节点(MeshInstance3D / StaticBody3D,表示画面里的方块) └── 字典数据 blocks(记录所有方块的坐标与类型,作为数据真相) 存放:遍历字典 -> 写成 JSON 文件 读取:解析 JSON 文件 -> 重新生成方块节点 移除:射线击中方块 -> 删除节点 -> 删除字典里对应的键

1.5 本集适合谁

这篇教程适合:

  • 刚刚看完 Godot 基础教程,想要做一个完整建造类游戏的开发者。
  • 已经实现了“放置方块”功能,但还不会做存档和移除功能的同学。
  • 想要理解 GDScript 字典和类型化字典区别的初学者。

在阅读本文前,建议先掌握 Godot 的基础场景结构、节点添加、简单的_ready()回调等知识。不需要完全精通射线检测,我会在对应小节里把步骤拆开讲清楚。

2. 环境准备与项目结构

2.1 Godot 版本说明

本文的代码以Godot 4.x的 GDScript 语法为主。类型化字典Dictionary[KeyType, ValueType]这类写法,建议使用Godot 4.2 或更高版本。如果你的项目还在使用 Godot 3.x,或者 4.0、4.1 等早期版本,可以把类型化字典退化成普通字典,代码核心逻辑不受影响。

关于版本,需要提醒一句:不同小版本的 API 会有细微差异,如果你的编辑器报错位置和本文不一致,优先查看当前项目的 Godot 版本,再对照官方文档确认对应函数名称。

2.2 示例项目的场景结构

为了使本教程可复现,我假设你已经有一个最基础的“第一人称建造”项目,场景树类似下面这样:

World (Node3D) ├── Player (CharacterBody3D) │ └── Camera3D ├── BlockContainer (Node3D) └── InteractionController (Node3D)

在这个结构里:

  • World是整个游戏世界的根节点,我建议把存档逻辑挂在这里。
  • BlockContainer专门用来存放所有已经生成的方块节点,方便统一管理。
  • InteractionController负责监听鼠标事件并执行射线检测。

如果你之前的项目结构不同,也没有关系,重点理解三个核心模块:

  1. 方块节点如何生成并挂载。
  2. 方块数据如何记录。
  3. 玩家交互如何调用删除。

2.3 方块场景 Block.tscn

为了让右键射线的碰撞检测正常工作,方块场景需要由三部分组成:

  • StaticBody3D:静态刚体,提供碰撞,供射线检测。
  • MeshInstance3D:可见的方块外观。
  • CollisionShape3D:碰撞盒形状。

同时,需要为根节点挂一个脚本,方便存储坐标和类型 ID。下面是我在项目中使用的Block.gd

# 文件路径:res://scripts/Block.gd extends StaticBody3D class_name WorldBlock # 记录方块所在网格坐标 var grid_pos := Vector3i.ZERO # 记录方块类型 ID:例如 1=草方块,2=石块 var type_id := 0

这里面的关键是把方块所在格子的坐标记录在节点身上。当射线碰撞到这个方块时,我们可以直接通过block.grid_pos拿到坐标,再用坐标去删除数据。

3. 类型化字典:从一个普通字典说起

3.1 字典的基础用法

在 GDScript 中,字典用花括号创建:

var player_data := { "name": "Alice", "hp": 100, "level": 3 }

通过键来取值:

print(player_data["name"]) # 输出 Alice print(player_data.hp) # 输出 100,字典也可以使用点语法

修改或新增键值:

player_data["hp"] = 80 player_data["exp"] = 1200

删除键:

player_data.erase("exp")

检查键是否存在:

if player_data.has("name"): print("存在 name 键")

如果只是写小型工具脚本,普通字典用起来很方便。但随着数据规模变大,问题也开始暴露。

3.2 普通字典的问题

如果项目中用普通字典记录方块,通常会这样写:

var blocks := {}

之后向里面添加数据时,可以这样放进去:

blocks[Vector3i(1, 2, 3)] = 1

看似没问题,但如果某天手误:

blocks["1,2,3"] = 1 # 键是字符串,不是 Vector3i

不会立刻报错,数据也能存进字典。但后续遍历时的处理逻辑会变得混乱,因为键的类型不稳定,有时是Vector3i,有时是String,很难排查。

另外当你尝试用点语法拉取编辑器自动补全时,普通字典就像一团“混沌”,编辑器无法告诉你键的类型。由于 GDScript 是动态语言,很多错误要等到运行时才暴露。

3.3 类型化字典的声明与用法

类型化字典的语法是这样的:

var blocks: Dictionary[Vector3i, int] = {}

一旦声明之后,编辑器就会检查:所有键必须是Vector3i,所有值必须是int,否则会提示类型不匹配。

还可以声明更常见的类型:

# 字符串键 -> 整数 var scores: Dictionary[String, int] = {} scores["gold"] = 100 # 坐标键 -> 节点对象 var block_nodes: Dictionary[Vector3i, StaticBody3D] = {} block_nodes[Vector3i(0, 0, 0)] = some_block_node # 字符串键 -> 布尔值 var settings: Dictionary[String, bool] = {} settings["audio_enabled"] = true

类型化字典的优势主要有三个:

  • 编译期给更多提示,减少运行时报错。
  • 在编辑器中更容易自动补全。
  • 代码可读性更高,一看声明就知道字典里存的是什么。

有一点需要清楚:类型化字典并不会带来非常明显的性能提升,它的主要价值是让代码更严谨和可维护。在游戏开发里,“能少一个隐藏 Bug”本身就很有意义。

3.4 直接对类型化字典序列化是不行的

在世界存档这个场景里,我们最关心的问题是如何把Dictionary[Vector3i, int]这种结构保存到文件。

看到这里你可能会想:直接用JSON.stringify(blocks)不就行了?

让我们来看实际情况。JSON 格式有一个前提:对象的键必须是字符串。但 GDScript 的字典键可以是任意类型,比如:

  • 整数作为键
  • Vector3i这种内置结构体作为键
  • 其他对象作为键

当你尝试直接序列化时,会遇到两类问题:

  1. 编辑器并不确定键能否被 JSON 正确转成字符串。
  2. 即使能转成字符串,读取的时候也很难恢复成原来的Vector3i

因此正确的做法是:在存档写入前,自行把Vector3i键转成约定的字符串格式;在读取存档时,再把字符串解析回Vector3i

3.5 一个可运行的字典小示例

为了检验你的 Godot 环境是否支持类型化字典,可以新建一个脚本,在_ready()里写:

# 文件路径:res://scripts/test_typed_dict.gd extends Node func _ready() -> void: var dict: Dictionary[String, int] = {} dict["apple"] = 3 dict["pear"] = 2 for key: String in dict: print(key, ": ", dict[key])

运行后可以输出:

apple: 3 pear: 2

注意for key: String in dict这种写法也用到了类型标注,这是 Godot 4.x 给 for 循环提供的类型推断写法。

4. 存档格式设计:方块世界的“档案表”

4.1 存档格式选择

Godot 支持多种持久化方式,包括:

  • ResourceSaver保存资源。
  • ConfigFile保存配置类数据。
  • FileAccess+ 自定义文本格式。
  • FileAccess+ JSON 序列化。

在 Godot 游戏开发中,JSON 是一种很直观、容易检查和调试的格式。因此世界存档保存的路径通常选择user://下,比如user://world_save.jsonuser://是 Godot 提供给项目专属数据目录的虚拟路径,不同系统会自动映射到对应的用户数据目录。

举个简单例子,你可以在 Godot 输出面板中打印绝对路径:

print(ProjectSettings.globalize_path("user://"))

4.2 推荐存档结构

因为 JSON 键必须是字符串,所以我推荐使用"x,y,z"作为键,方块类型 ID 作为值:

{ "version": 1, "blocks": { "0,1,0": 1, "1,2,3": 2, "4,0,2": 1 } }

这个结构有两个优点:

  • 可读性好,能直接用文本编辑器查看。
  • 结构清晰,方便以后扩展玩家位置、世界种子、日期等字段。

其中version字段非常重要。随着游戏功能发展,存档格式可能变化。如果将来增加了其他数据,并根据版本号做迁移,就能避免旧存档读取失败。

4.3 为什么坐标要用字符串拼接,而不是直接转成数组字符串

Vector3i(1, 2, 3)如果直接调用str(),在 Godot 4 里的输出形式类似:

(1, 2, 3)

这种格式包含括号和逗号空格,解析起来虽然也能做,但容易受格式差异影响。自定义的"1,2,3"格式则更稳定、更容易编写解析代码。

将位置转换为字符串的代码可以封装成一个辅助函数:

func _pos_to_key(pos: Vector3i) -> String: return "%d,%d,%d" % [pos.x, pos.y, pos.z]

反过来解析:

func _key_to_pos(key: String) -> Vector3i: var parts := key.split(",") if parts.size() != 3: return Vector3i.ZERO return Vector3i(int(parts[0]), int(parts[1]), int(parts[2]))

5. 世界存档完整实现

在这一节里,我们开始写完整的存档逻辑。这个脚本可以挂在World根节点上,内部持有blocks字典。

5.1 WorldController 完整脚本

为了避免逻辑混乱,这里我把功能集中在一个管理器脚本中。实际项目中你可以继续拆分成多个类,但初学阶段保持简单更容易理解。

# 文件路径:res://scripts/world_controller.gd extends Node3D class_name WorldController # 方块场景,在 Inspector 中指定 @export var block_scene: PackedScene # 方块父节点,用来统一管理方块 @export var block_container: Node3D # 核心数据:位置 -> 方块类型 ID var blocks: Dictionary[Vector3i, int] = {} const SAVE_PATH := "user://world_save.json" const SAVE_VERSION := 1 func _ready() -> void: if block_container == null: block_container = $BlockContainer # 如果存档存在就读取,否则创建初始世界 if not load_world(): _create_default_world() # 在某个网格坐标上放置方块 func place_block(pos: Vector3i, type_id: int, save_now := true) -> void: if blocks.has(pos): return if block_scene == null: push_error("block_scene 未设置") var block: WorldBlock = block_scene.instantiate() block_container.add_child(block) block.grid_pos = pos block.type_id = type_id block.position = Vector3(pos) blocks[pos] = type_id if save_now: save_world() # 从世界移除指定位置的方块 func remove_block(pos: Vector3i, save_now := true) -> void: if not blocks.has(pos): return for child in block_container.get_children(): if child is WorldBlock and child.grid_pos == pos: child.queue_free() break blocks.erase(pos) if save_now: save_world() # 清空世界中所有已生成的方块节点和数据 func _clear_all_blocks() -> void: blocks.clear() for child in block_container.get_children(): child.queue_free() # 保存当前世界到存档文件 func save_world() -> void: var save_data := { "version": SAVE_VERSION, "blocks": {} } for pos: Vector3i in blocks: var key := "%d,%d,%d" % [pos.x, pos.y, pos.z] save_data["blocks"][key] = blocks[pos] var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: push_error("无法打开存档文件: %s,错误码: %d" % [SAVE_PATH, FileAccess.get_open_error()]) return # 第二个参数使用 "\t" 可以格式化 JSON,方便阅读 file.store_string(JSON.stringify(save_data, "\t")) file.close() print("世界已保存:", SAVE_PATH) # 从存档文件中读取世界 func load_world() -> bool: if not FileAccess.file_exists(SAVE_PATH): return false var file := FileAccess.open(SAVE_PATH, FileAccess.READ) if file == null: return false var content := file.get_as_text() file.close() var parsed = JSON.parse_string(content) if not (parsed is Dictionary): push_error("存档解析失败,文件内容不是合法 JSON") return false var save_data: Dictionary = parsed # 读取存档前先清除当前场景中的所有方块 _clear_all_blocks() var block_data: Dictionary = save_data.get("blocks", {}) for key: String in block_data: var parts := key.split(",") if parts.size() != 3: continue var pos := Vector3i(int(parts[0]), int(parts[1]), int(parts[2])) var type_id := int(block_data[key]) # 从存档恢复时不再触发自动保存,防止刚读档就写文件 place_block(pos, type_id, false) print("世界加载成功,共恢复方块:", blocks.size()) return true # 当没有存档时,创建一个简单的出生点平台 func _create_default_world() -> void: for x in range(-2, 3): for z in range(-2, 3): place_block(Vector3i(x, 0, z), 1, false) # 在中间放一块石头作为标记 place_block(Vector3i(0, 1, 0), 2, false) save_world()

5.2 代码中几个关键设计点

place_blockremove_block都带了一个save_now参数,默认是true。这样普通鼠标操作会立即保存,而在创建默认世界或加载存档时,我们传false来避免频繁写磁盘。

sava_world里的循环使用for pos: Vector3i in blocks,依靠类型化字典的声明,能保证键是Vector3i。遍历时把键转换成字符串:

var key := "%d,%d,%d" % [pos.x, pos.y, pos.z]

读取存档时,先调用_clear_all_blocks()清空内存字典和场景节点,再通过place_block恢复方块。这样可以避免重复加载的情况,比如按了手动读档后,又把旧存档叠加到新世界上面。

5.3 为什么要把位置转成 Vector3 赋值

block.position = Vector3(pos)是一个比较简洁的写法:

Vector3(Vector3i(1, 2, 3)) # 结果等价于 Vector3(1.0, 2.0, 3.0)

场景节点位置使用浮点数,而网格坐标是整数,两者相乘的映射关系是 1 个格子等于 1 米。如果你的方块尺寸不是 1x1x1,你需要自己定义坐标转换函数,而不应该直接把整数坐标赋给 position。

5.4 运行与测试存档

创建好世界后,运行项目。如果一开始没有任何存档,_create_default_world()会生成一片平台和一个石块。当游戏关闭时,数据已经保存在user://world_save.json

为了验证,可以在 Godot 编辑器中关闭游戏,再重新运行。此时_ready()中的load_world()会检测到存档并恢复方块。

还可以在外部文本编辑器打开存档文件查看内容。如果你已经放置过许多方块,文件内容像是这样:

{ "version": 1, "blocks": { "-2,0,-2": 1, "-2,0,-1": 1, "-2,0,0": 1, "0,1,0": 2 } }

6. 右键移除工具实现

6.1 InteractionController 的定位

我们单独写一个脚本来管理交互。这个脚本运行在InteractionController节点上,它会监听鼠标输入,发射射线,然后调用WorldControllerremove_block

# 文件路径:res://scripts/interaction_controller.gd extends Node3D @export var world: WorldController @export var camera: Camera3D # 最大可拆除距离 @export var max_reach := 10.0 # 用于在 UI 打开时暂时关闭交互 @export var interaction_enabled := true func _unhandled_input(event: InputEvent) -> void: if not interaction_enabled: return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: _remove_block_from_cursor() # 防止其他节点再次响应这个事件 get_viewport().set_input_as_handled() func _remove_block_from_cursor() -> void: if world == null: return if camera == null: return var mouse_pos := get_viewport().get_mouse_position() var from := camera.project_ray_origin(mouse_pos) var to := from + camera.project_ray_normal(mouse_pos) * max_reach var query := PhysicsRayQueryParameters3D.create(from, to) query.collide_with_bodies = true query.collide_with_areas = false var hit := get_world_3d().direct_space_state.intersect_ray(query) if hit.is_empty(): return var block := hit.get("collider") as WorldBlock if block == null: return # 调用世界控制器删除方块,默认会立即保存 world.remove_block(block.grid_pos)

6.2 为什么监听 _unhandled_input 而不是 _input

_input会在所有 Node 之前收到事件,包括 UI 控件。在 Godot 里,如果一个按钮消费了鼠标点击事件,后续节点就不应该再响应它。使用_unhandled_input的时机要晚于 UI 处理,如果 UI 控件已经消费了事件,那么_unhandled_input不会再被调用。

所以在“点击 Button 打开菜单”时,菜单所在 UI 会先处理右键,不会导致玩家站在菜单前还能直接拆除方块。

需要提醒的是:_unhandled_input的准确触发时机取决于控件是否标记了鼠标过滤器。如果你希望 UI 左侧弹出的面板不拦截右键,也可以给控件设置mouse_filter = MOUSE_FILTER_IGNORE,但这不是本集重点。

6.3 射线检测详解

射线检测的核心原理是:

  1. 从相机位置向鼠标方向发射一条直线。
  2. 这条直线与场景中的碰撞体发生碰撞。
  3. 返回离相机最近的碰撞结果。

project_ray_origin的作用是把鼠标的二维坐标转换成世界空间中的起始点,也就是相机所在位置。

project_ray_normal会返回一个方向向量,从相机前方指向鼠标位置。

from + camera.project_ray_normal(mouse_pos) * max_reach表示射线终点。因为射线不能无限延伸,我们限制最大拆除距离为 10。

intersect_ray返回一个带有positionnormalcollider等信息的字典。其中collider是我们关心的碰撞体对象,也就是WorldBlock节点。

这里有一个易错点:block != null的判断要注意,如果射线打到了玩家或者其他碰撞体,这时直接不处理。

6.4 一个谨慎的处理:坐标从节点身上读取

你可能想过另一种做法:在射线击中的位置hit.position上做取整,然后删除对应坐标的方块。

例如:

var hit_pos: Vector3 = hit.position world.remove_block(Vector3i(hit_pos.floor()))

这种方法原理上也行,但容易出现边界偏差:射线击中方块边缘时,取整的结果可能落到方块侧面相邻的格子里,导致误删旁边方块。

相比之下,从被击中节点身上读取grid_pos更可靠。这也是我们把坐标存在方块节点上的价值:碰撞到哪个方块,就删除哪个方块,不存在位置换算偏差。

6.5 与左键放置工具的融合

如果你的项目中已经有了一个“左键放置方块”的脚本,可以让它与新的右键移除工具放在同一个InteractionController中,也可以拆成两个脚本。

推荐最简洁的方式,在同一个_unhandled_input中根据MOUSE_BUTTON_LEFTMOUSE_BUTTON_RIGHT分流:

func _unhandled_input(event: InputEvent) -> void: if not interaction_enabled: return if event is InputEventMouseButton and event.pressed: match event.button_index: MOUSE_BUTTON_LEFT: _place_block_from_cursor() get_viewport().set_input_as_handled() MOUSE_BUTTON_RIGHT: _remove_block_from_cursor() get_viewport().set_input_as_handled()

左键放置通常还需要先计算射线碰撞到的格子位置,然后偏移到相邻的空格,这块内容比较复杂。在这篇文章中,我们把重点放在右键移除和存档上。

7. 完整验证流程

7.1 场景连线

在使用前,如果你在InteractionController中通过@export暴露了worldcamera,记得在编辑器 Inspector 面板里拖拽引用:

  • world拖到World根节点。
  • camera拖到玩家身上的Camera3D

同样,普通字典方块场景需要在WorldControllerblock_sceneblock_container上做好引用。

7.2 操作测试

流程如下:

  1. 运行项目。
  2. 如果第一次进入,会在玩家脚下生成一个小平台。移动鼠标对准某个方块。
  3. 按下鼠标右键,方块消失。
  4. 再次关闭游戏并运行,会发现被拆除的方块没有恢复,而其他方块依然存在。
  5. 如果从外部打开存档文件,也能看到对应坐标已经不在blocks列表中。

7.3 预期运行结果

在没有更多粒子特效的前提下,控制台应当依次打印出类似内容:

世界加载成功,共恢复方块:42

或者当你手动放置或删除方块时:

世界已保存:user://world_save.json

这些打印日志方便你确认存档是否被写入。

8. 常见问题与排查思路

在实际开发中,新手经常会在存档或右键工具上遇到各种问题。我按照真实 Debug 经验整理了下面几种情况:

问题现象可能原因解决思路
类型化字典语法报错项目使用的 Godot 版本低于 4.2改为普通字典var blocks := {}
运行后世界是空的,没有默认平台加载逻辑读到了旧的空存档删除user://world_save.json后重新运行
右键点击没有反应没设置worldcamera引用检查@export变量是否在 Inspector 中赋值
右键点击被 UI 拦截UI 控件消费了鼠标事件使用_unhandled_input,或调整控件的mouse_filter
删除方块后,重新读档方块又出现删除后没有调用save_world确认remove_block默认的save_now=true
射线总是打不到方块方块没有碰撞体为 Block 场景添加CollisionShape3D,确认大小覆盖方块
手工编辑 JSON 后读档失败JSON 格式错误或键不符合x,y,z用 JSON 校验工具检查格式,并逐项对比约定格式
存档恢复时方块重叠读档前没有清理旧方块加载开始时调用_clear_all_blocks()

8.1 存档文件在哪个位置

如果找不到world_save.json,大概率是因为对user://路径没有概念。你可以在任意一个脚本中执行:

print(ProjectSettings.globalize_path("user://"))

打印出来的路径就是当前项目的专属用户目录,不同系统在不同位置。

8.2 JSON 被格式化后,为什么解析出来顺序不同

在 JSON 里,对象的键顺序可能不会和写入时保持一致。因此在读档循环中,不需要对顺序做任何假设,只需要以键作为定位依据即可。

8.3 一定要在每次删除时保存吗

本教程为了方便演示,让每次放置或删除都触发save_world()。这在方块数量少时完全没压力。但如果以后要做大世界,频繁写整个存档会导致卡顿。工程上可以选择:

  • 每 30 秒自动存档一次。
  • 按快捷键手动保存。
  • 玩家离开游戏时保存一次。
  • 放置或删除后设置一个“脏标记”,批量保存。

不要为了“保险”而盲目每帧调用写文件,磁盘 IO 是很昂贵的操作。

9. 最佳实践与工程建议

9.1 数据与表现分离

把整个世界的数据保存在blocks字典里,而不是反过来通过遍历场景节点来还原世界状态。场景里的块节点只负责被看见、被碰撞、被删除。这样即使你重新设计了方块外观,存档格式也可以保持不变;同样,存档丢失后,场景也能随时重建。

9.2 使用版本号保留迁移空间

任何时候都建议给存档文件增加version字段。将来如果增加“箱子中的物品”“方块额外属性”等数据,你可以根据版本号做不同的加载处理,不需要让新代码兼容所有旧格式,通过迁移脚本就可以解决。

9.3 类型化字典是好习惯,但别把它当银弹

类型化字典能够约束类型,但不代表数据内容一定正确。比如你记录了Vector3i(100, 100, 100),这个位置在世界上可能已经超出边界。所以游戏逻辑仍需要自己检查数值范围和坐标合法性。

另外一个细节是:GDScript 中字典的键必须具有可哈希性。普通整数、字符串、Vector3i都可以作为键,但某些复杂对象不一定可以。如果遇到“无法把对象作为键”一类的错误,优先考虑用字符串或者整数 ID 来代替。

9.4 导出变量让场景配置更灵活

在上面代码中,block_sceneblock_containercamera等都使用了@export。让美术或策划同事直接在 Inspector 中拖拽,而不是把路径写死在代码里,可以提高协作效率。

不过在初始化时如果没设置,你要记得做空引用判断,避免直接空指针。

9.5 关于保存路径的安全意识

当你把存档路径改成user://world_save.json时,游戏本身运行在自己的专属目录下,没有系统级写入风险。但在做任何涉及文件的操作前,请记住三条底线:

  • 不直接覆盖重要原文件,必要时先备份。
  • 先写临时文件,测试无误后再替换正式文件。
  • 在别人电脑上运行时,不要假设磁盘可写或权限足够。

9.6 后期可以继续完善的方向

这篇文章实现的是一个完整的可运行功能,但离正式商业项目还有一段距离。后续可以做这些扩展:

  • 存档增加玩家位置、朝向、背包数据。
  • 使用二进制或压缩格式减少存档体积。
  • 添加手动读档存档界面。
  • 实现区域分块,只保存被修改过的区块。
  • vector key改用整数编码以提升性能,例如把(x,y,z)编码成一个整数。

这些扩展并不是必须在第一版就全部写完,但数据结构上留好了version和独立字典,后面接起来会比较顺手。

9.7 对新手补充一句

如果你正在做一个弹幕游戏、横版过关或者 RPG,本文中的很多技巧同样是通用的。字典是一种内存中的组织方式,存档是一种持久化的组织方式,输入交互是玩家与游戏世界沟通的入口。无论画面是 2D 还是 3D,只要涉及“很多个对象需要被记录”,你就能用这些思路改造出合适的解决方案。

10. 收尾与下一步

到这里,我们已经完成了三件事:世界可以被保存成 JSON 文件,方块数据使用类型化字典管理,玩家可以用鼠标右键把方块从场景和存档中同步移除。你不再需要在每次关闭 Godot 前拍照记录自己搭了什么,因为存档会记住整个世界。

下一步,你可以继续给世界增加更多方块类型,给不同类型的方块提供不同材质;也可以把左键放置功能从“只能放床边砖”升级成有多种物品可以选择。当你在编辑器里体验到自己放上去、自己拆下来、存档还始终准确的时候,就会发现:一套清晰的数据管理方案,比华丽的画面更能降低开发成本。

如果你在实测中遇到了问题,不要着急,优先级最高的事情是确认三个引用链路:场景节点引用是否正确,方块碰撞体是否存在,存档文件内容是否符合预期。想清楚“数据存在哪里、节点在哪里显示、事件从哪里触发”,这一类功能以后再也不会成为你的拦路虎。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 21:52:41

2026 Codex怎么开通?Plus / Pro完整开通教程

如果你的主要目的就是使用 Codex,准备升级 ChatGPT Plus 或 Pro,其实整个流程并不复杂。先说明一点:Codex 目前已经包含在 ChatGPT 各类套餐中,包括 Free 和 Go,并不是必须单独购买一个“Codex会员”。Plus 和 Pro 的主…

作者头像 李华
网站建设 2026/9/4 21:49:48

MindSpore大模型预训练实战:从环境配置到分布式训练全攻略

1. 环境准备与版本选型 1.1 MindSpore版本选择与CUDA/PyTorch兼容性对照 先说个最让人头疼的问题——版本匹配。我用MindSpore做LLM预训练前后折腾了不少时间,中间踩过最多的坑就是MindSpore、CUDA、PyTorch和Transformers这四者之间的版本兼容关系。很多同学在群里…

作者头像 李华
网站建设 2026/9/4 21:47:43

反射式DLL加载器深度解析:从PE结构到内存加载原理

反射式 DLL 加载器(Reflective DLL Loader)听起来很底层、很难懂,但它真正做的事情并不复杂:在 C 程序里,不调用 Windows 自带的LoadLibrary,而是把一个 DLL 从内存中自行加载起来。很多文章一提反射式加载…

作者头像 李华
网站建设 2026/9/4 21:47:25

用Godot引擎构建Windows C盘清理工具:PowerShell扫描与回收站策略

C 盘剩余空间跌破 10 GB 时,Windows 的运行体验会迅速劣化:系统更新装不上、软件频繁提示磁盘空间不足、休眠和虚拟内存文件也可能异常。普通用户第一反应是用系统自带的“存储设置”清理,但系统清理对临时目录、缓存、下载目录和 Windows.ol…

作者头像 李华
网站建设 2026/9/4 21:45:39

基于大模型与CodeAgent的自动化知识图谱本体构建平台实践

简介:OntoMind 是一款面向语义知识工程领域的专业级智能本体构建平台,面向AI工程师、知识图谱开发者与行业知识治理人员,解决传统本体构建中人工成本高、跨模态融合难、推理可解释性弱等核心痛点,适用于金融、医疗、政务等强语义合…

作者头像 李华
网站建设 2026/9/4 21:45:08

本地部署大模型实战:从量化选型到日常应用的全流程踩坑指南

先说个我自己的真实起点:在真正动手之前,我一直以为“本地部署大模型”是那种只有备了多张显卡、能熟练写 CUDA 代码的人才能碰的事。后来因为要把一些内部文档做摘要、又不想把内容传到公网 API,我才硬着头皮试了一遍,结果发现&a…

作者头像 李华