ARTICLE DETAIL

资讯详情

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

Godot 4 世界存档实战:类型化字典与右键移除工具

Godot 4 世界存档实战:类型化字典与右键移除工具 大家好欢迎继续来到我们这个手把手带你入门 Godot 游戏开发的系列教程。在上一期里我们已经实现了角色移动、地图搭建等基础功能。不过很多朋友在实际动手时都会遇到一个很现实的问题好不容易搭好的小世界刷新一次就回到原样辛辛苦苦生成的道具、改过的场景状态关了游戏就全没了。这显然不是一个“游戏”该有的样子。那么从这一期开始我们就来解决这个问题同时也会引入一个非常实用的语法技巧类型化字典还会给我们的编辑器或游戏界面增加一个效率小工具右键移除工具。换句话说这一期内容包含三件事用 Godot 自带的能力做“世界存档”与读档学会使用类型化字典来管理存档数据实现一个右键点击即可移除物品/对象的交互工具。无论你是刚接触 Godot 的新手还是已经写过一点 GDScript 的初学者这篇教程都可以跟着一步步做完。代码会全部给出并且每一步都会解释为什么要这么做。老规矩本文使用 Godot 4.x 版本GDScript 语言重点演示思路版本差异会在文末说明。1. 为什么需要世界存档与类型化字典1.1 什么是世界存档在世界构建类游戏或带有可探索地图的 2D 游戏中“世界存档”指的是把当前场景中所有需要保留的状态保存下来并在下一次启动游戏时恢复。举个最简单的例子你的游戏地图上放了一些宝箱玩家打开宝箱后宝箱变成已打开状态。如果不做存档玩家退出游戏再进来宝箱又变回未打开状态。这种情况在开发阶段可以接受但在正式游戏里肯定不行。世界存档的核心目标就是保存关键对象的状态保存玩家的位置、血量、背包等角色数据保存场景中可交互物体的状态在游戏启动时读取这些状态并恢复场景。1.2 为什么需要类型化字典GDScript 中的字典Dictionary是一种键值对数据结构类似于其它语言里的 Map 或对象。普通的字典写法是这样的var data { player_hp: 100, coin: 10 }这样写当然没有问题但在大型项目或多人协作时普通字典有一个麻烦键名容易拼错取值类型不固定编译时很难提前发现错误。类型化字典Typed Dictionary可以理解为给字典的键和值都限定类型例如var save_data: Dictionary[String, Variant] {}这句话表示这个字典的键必须是 String 类型值可以是任意类型。如果你试图用一个 int 作为键编辑器就会直接提示错误。使用类型化字典的好处非常明显提前发现类型错误减少运行期崩溃代码可读性更高在编辑器里自动补全更友好配合存档系统管理数据更规范。1.3 右键移除工具是什么场景“右键移除工具”在游戏开发里通常有两种理解方式。第一种编辑器中需要点选场景物体然后右键删除属于编辑器工作流优化。第二种玩家在游戏内通过右键点击某个物品或建筑可以把它移除或放回背包。本节课我们先按第二种来实现因为在游戏内写一个右键移除工具更方便大家验证存档与读档是否生效。比如玩家在场景里创建了一个石头然后右键把它移除下次读档时石头就真的不在了。这样你就知道存档系统有没有把对象列表正确保存下来。2. 环境准备与项目结构2.1 使用版本本文示例基于 Godot 4.x以 4.2 或 4.3 为例GDScript 语法在 Godot 4.x 中已经比较稳定。不同小版本之间差异不大如果你使用的是 Godot 3.x部分 API 会有所不同稍后会在注意事项中说明。2.2 创建项目打开 Godot 项目管理器点击“新建项目”项目名称可以取为WorldSaveDemo渲染器选择“Forward”、“Mobile”或“Compatibility”都可以本教程不涉及复杂渲染特性选择默认的 Forward 或兼容模式均可。2.3 目录结构规划为了后续更好扩展我们建议按下面的结构组织文件WorldSaveDemo/ ├── project.godot ├── scenes/ │ ├── main.tscn │ └── item.tscn ├── scripts/ │ ├── main.gd │ ├── item.gd │ └── save_manager.gd └── assets/ └── (图标或贴图资源可以先用占位颜色代替)这里我们把场景和脚本分开实际开发中尤其是项目变大以后清晰的文件结构能帮你省下大量找文件的时间。2.4 准备一个测试用简单场景为了不让教程卡在美术资源上我们全部使用 Godot 内置的 ColorRect 或 Polygon2D 来代表物体。新建一个主场景根节点使用 Node2D命名为 Main并挂载脚本main.gd。再新建一个 Item 场景根节点使用 Area2D挂载脚本item.gd内部添加一个 CollisionShape2D 和一个 ColorRect用于代表一个可交互对象。这部分会在下一节逐一演示。3. 类型化字典在存档中的用法3.1 普通字典 vs 类型化字典为了让大家理解更直观我们先写一个最小对比示例。普通字典var save_data {} save_data[player_position] Vector2(100, 200) save_data[hp] 80类型化字典var save_data: Dictionary[String, Variant] {} save_data[player_position] Vector2(100, 200) save_data[hp] 80你可能会说这看起来不是一样吗区别在哪儿区别在当你误写键名类型时类型化字典会在编辑器中提示错误而普通字典会默默创建一个新键。再看一个更细化的例子我们可以限定值为整数var score: Dictionary[String, int] { game_score: 0, best_score: 100 } score[game_score] 200 # 正常 # score[player_name] Tom # 编辑器会报警因为值是 String不符合 int当我们需要保存玩家位置、血量、场景里所有物品的坐标列表时直接全用Dictionary[String, Variant]可能更省事因为值类型丰富。3.2 存档数据结构设计在 Godot 里保存数据到磁盘最常用的方式是 JSON。JSON 只支持基础类型例如数字、字符串、数组、字典以及由它们嵌套组成的结构不支持直接存储 Vector2、Color、NodePath 等 Godot 对象所以我们在存档前需要把坐标等数据手动转换一下。例如玩家位置用 Vector2(100, 200) 表示存档时必须写成{ player_position: {x: 100, y: 200} }或者简单一点直接保存数组{ player_position: [100, 200] }读取后你再把数组转回 Vector2。为了让数据更有序建议给存档文件设计一个大结构用类型化字典来定义整体格式# 存档结构示例 var save_schema: Dictionary[String, Variant] { version: 1, player: { position: Vector2(0, 0), hp: 100, }, world_objects: [] }实际写入 JSON 前再做一次类型转换。3.3 JSON 与文件的读写流程Godot 中保存文件的基本流程是组装要保存的数据字典用JSON.stringify()把字典转换成字符串用FileAccess.open()打开文件把字符串写入文件关闭文件。读取流程与之相反用FileAccess.open()打开文件读取全部字符串用JSON.parse_string()把字符串转回字典根据字典恢复游戏场景。这一段非常关键我们马上在实战案例中完整实现。4. 完整实战世界存档 右键移除工具4.1 创建基础场景我们先来做一个小的可保存世界。项目结构如下scenes/main.tscn scenes/item.tscn scripts/main.gd scripts/item.gd scripts/save_manager.gd创建 Item 场景Item 代表世界中可以被右键移除的对象。新建一个场景根节点选择 Area2D节点名改为Item保存为scenes/item.tscn。添加子节点CollisionShape2D用于点击检测ColorRect用于显示图形选中 Item 根节点在节点面板里添加脚本scripts/item.gd。暂时让item.gd内容保持极简# 文件路径scripts/item.gd extends Area2D # 物品ID用于存档时区分不同物体 export var item_id: String item_default func _ready() - void: # 为了演示方便默认给物品配一个圆形碰撞 var shape : CollisionShape2D.new() var circle : CircleShape2D.new() circle.radius 16.0 shape.shape circle add_child(shape) # 显示为一个颜色块方便看到如何被移除 var rect : ColorRect.new() rect.size Vector2(32, 32) rect.color Color(0.2, 0.7, 0.9) add_child(rect)这里我们用代码动态创建 CollisionShape2D 和 ColorRect这样就不用反复拖节点了。_ready()中动态生成的对象也能正常参与场景交互。如果你想在编辑器里看得更清楚也可以在场景中手动创建这些节点。动态创建的好处是 Item 场景复用更方便。创建 Main 场景新建一个场景根节点 Node2D命名Main保存为scenes/main.tscn挂载scripts/main.gd。在 Main 场景中点击运行程序启动后会加载一个空世界。4.2 保存管理器 SaveManager我们把存档逻辑单独放到一个脚本中让主场景代码更清晰。新建脚本scripts/save_manager.gd注意它不继承 Node而是普通 RefCounted# 文件路径scripts/save_manager.gd extends RefCounted ## 存档管理器负责把游戏数据编码为 JSON 并写入磁盘 const SAVE_PATH : user://world_save.json const SAVE_VERSION : 1 ## 保存世界数据 ## data 是一个可被 JSON 序列化的 Dictionary func save_world(data: Dictionary[String, Variant]) - bool: var json_str : JSON.stringify(data, \t) var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file null: push_error(无法打开存档文件进行写入: , SAVE_PATH) return false file.store_string(json_str) file.close() print(存档成功文件路径: , SAVE_PATH) return true ## 读取世界数据 ## 如果文件不存在或解析失败返回空字典 func load_world() - Dictionary[String, Variant]: if not FileAccess.file_exists(SAVE_PATH): print(没有找到存档文件: , SAVE_PATH) return {} var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: push_error(无法打开存档文件进行读取: , SAVE_PATH) return {} var content : file.get_as_text() file.close() var json_data: Variant JSON.parse_string(content) if json_data is Dictionary: return json_data else: push_error(存档文件格式不正确) return {}这段代码做了四件事user://是 Godot 提供的用户目录不同平台会自动映射到对应位置非常适合存放存档文件JSON.stringify(data, \t)第二个参数让 JSON 输出带缩进方便用文本编辑器检查FileAccess.open()返回空表示打开失败读取时先检查文件是否存在防止报错。4.3 把数据类型转换封装好由于 JSON 不支持直接保存 Vector2我们在 main.gd 中定义两个辅助函数# 文件路径scripts/main.gd部分 ## 把 Vector2 转成可序列化数组 func vector2_to_array(vec: Vector2) - Array[float]: return [vec.x, vec.y] ## 把数组恢复成 Vector2 func array_to_vector2(arr: Array) - Vector2: if arr.size() 2: return Vector2(arr[0], arr[1]) return Vector2.ZERO4.4 玩家角色与右键检测为了让世界存档更直观我们给 Main 场景添加一个简单的“玩家”节点。这里不使用角色控制器只用代码标记一个位置点。你可以用下面这种最简单的方式在 main.gd 中声明一个player_position每次点击鼠标左键表示玩家移动到点击位置。# 文件路径scripts/main.gd核心部分 extends Node2D const ItemScene : preload(res://scenes/item.tscn) var save_manager : SaveManager.new() var player_position : Vector2(400, 300) # 当前世界中的所有物品节点 var world_items: Array[Node] []不过在运行场景之前我们需要先处理一个问题如果游戏场景里有可点击的 Item而鼠标点击事件同时被 Main 捕获就可能导致左键移动玩家、右键移除物品时互相干扰。为了避免这种情况我们把“右键移除”逻辑放在 Item 自己的 Area2D 信号中而不是在 Main 里统一判断全局鼠标事件。这样点击哪个物品就由哪个物品自己响应。4.5 在 Item 中实现右键移除修改item.gd# 文件路径scripts/item.gd extends Area2D # 物品ID export var item_id: String item_default # 可选自定义物品颜色 export var item_color: Color Color(0.2, 0.7, 0.9) func _ready() - void: input_pickable true # 碰撞层设置让鼠标事件能命中 collision_layer 1 collision_mask 1 # 如果场景中已经手动添加了形状不重复添加 if not has_node(CollisionShape2D): var shape : CollisionShape2D.new() shape.name CollisionShape2D var circle : CircleShape2D.new() circle.radius 16.0 shape.shape circle add_child(shape) if not has_node(Visual): var rect : ColorRect.new() rect.name Visual rect.size Vector2(32, 32) rect.color item_color add_child(rect) # ColorRect 默认位置是从 (0,0) 开始为了让中心点对齐碰撞体需要向左上偏移 rect.position Vector2(-16, -16) func _input_event(viewport: Node, event: InputEvent, shape_idx: int) - void: if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_RIGHT and event.pressed: # 通知主场景移除自己 var main : get_tree().current_scene if main and main.has_method(remove_item_by_node): main.remove_item_by_node(self)这里注意_input_event是 Area2D 的一个重要信号回调方法当该区域被鼠标点击时会自动调用。当玩家右键点击物品时Item 自己不直接queue_free()而是调主场景的remove_item_by_node()这样做的好处是主场景可以根据需要决定是否先存档而 Item 不需要关心全局逻辑。4.6 在主场景中生成物品并处理移除现在回到main.gd我们实现生成物品、移除物品、保存存档、读取存档的整套逻辑。# 文件路径scripts/main.gd extends Node2D const ItemScene : preload(res://scenes/item.tscn) const SaveManagerScript : preload(res://scripts/save_manager.gd) var save_manager: SaveManagerScript # 当前世界中的所有物品节点 var world_items: Array[Node] [] func _ready() - void: save_manager SaveManagerScript.new() # 游戏启动时自动尝试读取存档 load_game() func _unhandled_input(event: InputEvent) - void: # 左键点击把玩家位置移动到目标点并生成一个临时的位置标识 if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_LEFT and event.pressed: var target_position: Vector2 get_global_mouse_position() player_position target_position # 在点击位置生成一个新物品方便演示状态保存 spawn_item_at(target_position)这段代码目前有一个问题玩家左键点击一下就会生成一个新物品可能导致物品越来越密集。但我们是为了测试存档方便所以先保留这个逻辑大家可以后续改成“从背包中选择某个物品再放置”的完整回合制流程。我们继续补充生成物品与移除物品的方法# 文件路径scripts/main.gd加入以下函数 # 玩家当前位置实际项目中可以替换成角色节点的全局坐标 var player_position : Vector2(400, 300) ## 在指定位置生成一个物品 func spawn_item_at(pos: Vector2, item_id: String ) - void: var item : ItemScene.instantiate() item.position pos # 如果没有手动指定 ID则用一个按时间生成的名字 if item_id : item.item_id item_%d % len(world_items) else: item.item_id item_id # 放到场景中 add_child(item) world_items.append(item) ## 被 item.gd 调用的移除方法 func remove_item_by_node(item_node: Node) - void: if item_node in world_items: world_items.erase(item_node) item_node.queue_free() # 移除后自动保存一次确保下次读档后该物品不会复活 save_game()4.7 保存世界数据的具体实现接下来是核心保存时我们需要把场景中所有 Item 的位置、ID 都转成 JSON 友好的结构。每次物品生成时我们都给它赋予不同的 item_id再次启动时如果存档中存在该 ID就不重复生成。这样能保证世界状态相对稳定。但这里有一个问题需要想清楚我们目前是在每次点击左键时都生成一个新物品那么同一个 item_id 可能会重复出现吗要避免这个问题可以配合“物品初始点标记表”做不过为了这节课演示方便我们暂时允许生成重复物品。真正的保存和读档逻辑如下保存时把所有存在物品的 item_id 和位置写入数组读取时先清空当前所有物品再根据数组重新生成。这样做有一个明显结果如果游戏过程中点击生成了三个物品然后又右键删除了一个最后保存的文件里只有两个物品。下次读档后界面恢复为两个物品。这套逻辑可以如实反映场景中物品的增删我们用任意数量的临时物品来测试存档恢复。# 文件路径scripts/main.gd保存功能 ## 执行游戏数据保存 func save_game() - void: # 构建可序列化的存档数据 var world_object_list: Array[Dictionary] [] for item in world_items: if not is_instance_valid(item): continue var object_data : { id: item.item_id, x: item.position.x, y: item.position.y, color: item.item_color.to_html(), } world_object_list.append(object_data) var data: Dictionary[String, Variant] { version: 1, player_position: vector2_to_array(player_position), world_objects: world_object_list, } save_manager.save_world(data)值得注意的地方item.item_color.to_html()把颜色转成十六进制字符串例如 “3399cc”这样可以方便地存进 JSONplayer_position用数组保存world_objects是一个数组数组内是多个字典。4.8 读取世界数据的具体实现# 文件路径scripts/main.gd读取功能 ## 执行游戏数据读取 func load_game() - void: var data : save_manager.load_world() if data.is_empty(): # 如果没有存档就创建两个初始物品作为演示 spawn_item_at(Vector2(600, 300), start_item_1) spawn_item_at(Vector2(700, 400), start_item_2) return # 先清理当前世界的物品 clear_all_items() # 恢复玩家坐标 if data.has(player_position): var saved_pos: Array data[player_position] player_position array_to_vector2(saved_pos) # 恢复物品列表 if data.has(world_objects): var saved_objects: Array data[world_objects] for obj in saved_objects: if obj is Dictionary: var item_id: String obj.get(id, unknown) var pos_x: float obj.get(x, 0) var pos_y: float obj.get(y, 0) var color_html: String obj.get(color, ) spawn_item_at_with_color(Vector2(pos_x, pos_y), item_id, color_html) ## 清空当前世界所有物品 func clear_all_items() - void: for item in world_items: if is_instance_valid(item): item.queue_free() world_items.clear() ## 带颜色恢复的生成方法 func spawn_item_at_with_color(pos: Vector2, item_id: String, color_html: String) - void: var item : ItemScene.instantiate() item.position pos item.item_id item_id if color_html ! : item.item_color Color.html(color_html) add_child(item) world_items.append(item)需要注意的是在load_game()中如果调用clear_all_items()之后再加载会清空当前场景中的所有 Item。因为我们只在_ready()时调用一次load_game()所以不存在重复清理的问题。如果你想在游戏运行时手动“读档”要注意清空动作和 Item 的信号断开顺序。最稳妥的做法是先把所有 Itemqueue_free()然后使用await等待一帧再生成新物体。不过作为简单教学示例我们先不在运行中强制读档只保留“启动时自动读档”和“变化后自动存档”两种行为。4.9 运行与预期效果现在运行项目初始状态下没有存档文件所以 Main 场景会生成两个初始物品start_item_1和start_item_2。操作过程点击鼠标左键在点击位置生成一个新物品鼠标右键点击任意物品该物品会从场景中消失关闭游戏再重新运行程序会自动读取之前的存档恢复玩家位置和物品列表。预期效果刚才生成但未被删除的物品还在被右键移除的物品不会再出现。同时在 Godot 的输出窗口会看到类似日志存档成功文件路径: user://world_save.json存档文件实际存放位置可以通过打开项目目录下的user://对应文件夹查看。不同平台路径不同我们可以在运行时打印ProjectSettings.globalize_path(user://)来得到完整路径print(ProjectSettings.globalize_path(user://))4.10 代码保存与监听器细化到这里一个最简单的“世界存档 右键移除”流程已经完成。不过还要提醒一个非常容易踩的坑如果 Item 是被动态生成的那么它上面的 Item 场景脚本是否已经正确挂载决定了_input_event能否触发。如果右键点击没有反应请确认Item 根节点是 Area2Dinput_pickable true场景中至少有一个 CollisionShape2D碰撞形状的半径、大小没有为 0。如果使用 ColorRect 作为表现层请确认它不会挡住 Area2D 的鼠标检测。Area2D 的鼠标点击检测基于碰撞体而不是基于 ColorRect 的矩形区域所以只要 CollisionShape2D 正常即可。4.11 让物品支持更多类型用颜色区分不同对象刚才我们生成的所有 Item 都用同一种颜色。实际项目中不同类型对象在地图上会有不同的表现。我们把 item.gd 里的export var item_color利用到最大使得每个物品在保存时把颜色也保存下来。比如我们可以在spawn_item_at中根据传入的颜色参数生成不同颜色的物品# 在 spawn 时手动指定颜色示例 func spawn_colored_item_at(pos: Vector2, type: String) - void: var item : ItemScene.instantiate() item.position pos item.item_id %s_%d % [type, len(world_items)] match type: tree: item.item_color Color(0.1, 0.8, 0.2) stone: item.item_color Color(0.5, 0.5, 0.5) treasure: item.item_color Color(0.9, 0.8, 0.1) add_child(item) world_items.append(item)这样你在场景里生成的每种对象都有独立的外观读档后依然能恢复正确的颜色。这就是一种简化版“类型化对象管理”思路。5. 完整代码总览为了让新手直接复制使用这里把主要代码整合展示一次。文件结构如下。5.1 scripts/save_manager.gd# 文件路径scripts/save_manager.gd extends RefCounted ## 存档管理器 const SAVE_PATH : user://world_save.json ## 保存世界数据 func save_world(data: Dictionary[String, Variant]) - bool: var json_str : JSON.stringify(data, \t) var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file null: push_error(无法打开存档文件进行写入: , SAVE_PATH) return false file.store_string(json_str) file.close() print(存档成功: , SAVE_PATH) return true ## 读取世界数据 func load_world() - Dictionary[String, Variant]: if not FileAccess.file_exists(SAVE_PATH): print(没有找到存档文件: , SAVE_PATH) return {} var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: push_error(无法打开存档文件进行读取: , SAVE_PATH) return {} var content : file.get_as_text() file.close() var json_data: Variant JSON.parse_string(content) if json_data is Dictionary: return json_data else: push_error(存档文件格式不正确) return {}5.2 scenes/item.tscn 与 scripts/item.gditem.tscn中根节点 Area2D 脚本。核心脚本如下# 文件路径scripts/item.gd extends Area2D export var item_id: String item_default export var item_color: Color Color(0.2, 0.7, 0.9) func _ready() - void: input_pickable true collision_layer 1 collision_mask 1 if not has_node(CollisionShape2D): var shape : CollisionShape2D.new() shape.name CollisionShape2D var circle : CircleShape2D.new() circle.radius 16.0 shape.shape circle add_child(shape) if not has_node(Visual): var rect : ColorRect.new() rect.name Visual rect.size Vector2(32, 32) rect.color item_color rect.position Vector2(-16, -16) add_child(rect) func _input_event(viewport: Node, event: InputEvent, shape_idx: int) - void: if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_RIGHT and event.pressed: var main : get_tree().current_scene if main and main.has_method(remove_item_by_node): main.remove_item_by_node(self)5.3 scripts/main.gd# 文件路径scripts/main.gd extends Node2D const ItemScene : preload(res://scenes/item.tscn) const SaveManagerScript : preload(res://scripts/save_manager.gd) var save_manager: SaveManagerScript var world_items: Array[Node] [] var player_position : Vector2(400, 300) func _ready() - void: save_manager SaveManagerScript.new() load_game() func _unhandled_input(event: InputEvent) - void: if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_LEFT and event.pressed: var target_position: Vector2 get_global_mouse_position() player_position target_position spawn_item_at(target_position) func spawn_item_at(pos: Vector2, item_id: String ) - void: var item : ItemScene.instantiate() item.position pos if item_id : item.item_id item_%d % len(world_items) else: item.item_id item_id add_child(item) world_items.append(item) func spawn_item_at_with_color(pos: Vector2, item_id: String, color_html: String) - void: var item : ItemScene.instantiate() item.position pos item.item_id item_id if color_html ! : item.item_color Color.html(color_html) add_child(item) world_items.append(item) func remove_item_by_node(item_node: Node) - void: if item_node in world_items: world_items.erase(item_node) item_node.queue_free() save_game() func save_game() - void: var world_object_list: Array[Dictionary] [] for item in world_items: if not is_instance_valid(item): continue var object_data : { id: item.item_id, x: item.position.x, y: item.position.y, color: item.item_color.to_html(), } world_object_list.append(object_data) var data: Dictionary[String, Variant] { version: 1, player_position: vector2_to_array(player_position), world_objects: world_object_list, } save_manager.save_world(data) func load_game() - void: var data : save_manager.load_world() if data.is_empty(): spawn_item_at(Vector2(600, 300), start_item_1) spawn_item_at(Vector2(700, 400), start_item_2) return clear_all_items() if data.has(player_position): var saved_pos: Array data[player_position] player_position array_to_vector2(saved_pos) if data.has(world_objects): var saved_objects: Array data[world_objects] for obj in saved_objects: if obj is Dictionary: var item_id: String obj.get(id, unknown) var pos_x: float obj.get(x, 0) var pos_y: float obj.get(y, 0) var color_html: String obj.get(color, ) spawn_item_at_with_color(Vector2(pos_x, pos_y), item_id, color_html) func clear_all_items() - void: for item in world_items: if is_instance_valid(item): item.queue_free() world_items.clear() func vector2_to_array(vec: Vector2) - Array[float]: return [vec.x, vec.y] func array_to_vector2(arr: Array) - Vector2: if arr.size() 2: return Vector2(arr[0], arr[1]) return Vector2.ZERO运行后操作逻辑已经完整。6. 在编辑器或游戏内进一步扩展右键移除很多朋友做的是“模拟经营”或“沙盒建造”类玩法右键移除工具往往与工具栏、背包一起出现。我们可以把右键移除工具进一步扩展成三种设计思路6.1 方案一无差别右键移除当前主线示例就是这种任何 Item 都可以直接右键删除。如果业务简单这种方式最省事。缺点是无法对物体做“必须持有对应道具才能移除”的限制。6.2 方案二进入“移除模式”后才能右键删除这种方式更像真实工具切换。例如 Main 场景维护一个状态enum ToolMode { NONE, REMOVE, BUILD } var current_tool_mode : ToolMode.NONE在 Item 的_input_event中检查主场景当前模式func _input_event(viewport: Node, event: InputEvent, shape_idx: int) - void: if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_RIGHT: if not (event as InputEventMouseButton).pressed: return var main : get_tree().current_scene if main and main.current_tool_mode main.ToolMode.REMOVE: main.remove_item_by_node(self)这样只有一个“移除工具”被选中时才响应避免误删。6.3 方案三编辑器场景中的右键移除如果你需要的是“在 Godot 编辑器中快速删除节点”那么不需要写任何代码直接在场景面板中选中节点按 Delete 键即可。但是如果你在做批量工具或自定义插件希望右键点击某个可视对象时调用自己的脚本则可以通过EditorScenePostImport或EditorPlugin实现。这个小节我们先不展开因为工程插件开发需要单独一篇完整教程。游戏内右键工具更通用也更适合新手练习。7. Godot 4 中类型化字典的进阶用法7.1 字典内嵌套数组在存档里我们经常需要类似这样复杂的数据结构var data: Dictionary[String, Variant] { player: { name: Tom, position: [100, 200] }, items: [ {id: sword, count: 1}, {id: potion, count: 5} ] }不过Dictionary[String, Variant]在解析时不会递归强制类型校验它只约束最外层键值对。因此内层数据仍需要我们自己保证结构正确。7.2 类型化数组除了类型化字典Godot 4 还提供了类型化数组var item_ids: Array[String] [] var positions: Array[Vector2] []类型化数组在和类型化字典配合时非常有用。例如var object_ids: Array[String] [stone_1, stone_2, tree_1]如果你在 for 循环中不小心把一个非 String 对象加进去编辑器也会立刻提示。7.3 类型化字典的局限类型化字典并不总能完全阻止运行时错误。比如从 JSON 反序列化得到的字典它是普通的无类型 Dictionary并不是类型化字典因此我们在load_game()里仍然使用Dictionary[String, Variant]接收后再手动检查。这告诉我们一个实践原则类型化语法主要作用在编码阶段帮助开发者更早发现逻辑问题但数据合法性校验仍然不能省略。8. 常见问题与排查思路8.1 右键点击 Item 没有效果现象运行后鼠标左键生成物品正常但右键点击 Item 一直没反应。常见原因和排查步骤问题现象可能原因解决思路右键点击无反应Area2D 的input_pickable为 false在 _ready() 中设置input_pickable true右键点击无反应没有 CollisionShape2D检查场景中是否添加碰撞体右键点击无反应碰撞层/掩码没有与鼠标检测层匹配将 collision_layer 和 collision_mask 设为至少包含 1右键点击无反应_input_event中判断事件类型错误确认是InputEventMouseButton且右键是MOUSE_BUTTON_RIGHT点击事件被上层节点拦截场景中有 Control 节点盖住了 Area2D检查是否有全屏 ColorRect 或 Panel 拦截鼠标Item 在移除后又自动出现点击左键生成新物品的 IDs 重复检查 item_id 生成逻辑避免读档后重复生成如果你使用的是鼠标全局事件来检测需要注意_unhandled_input和_input_event的优先级差异。我们这里使用了 Area2D 自身的鼠标事件能够精确命中具体物体。8.2 存档文件一直是空的现象游戏内保存成功但打开文件发现只有最简单的结构物品数据为空。原因通常是在保存前world_items数组里没有更新到最新节点。调用queue_free()后节点不会立刻释放如果马上遍历它对象依然是有效的可能造成“刚删除但还能保存”的假象。更稳妥的做法是基于数据层来标记已移除状态或者直接在移除后把对象从数组中 erase然后才queue_free()。8.3 JSON 序列化时报错如果你试图直接把 Vector2 放进 JSON会得到类似Invalid type之类的错误。解决方式是像我们上面那样将 Vector2 转换为普通数组。8.4 读档后重复生成物品原因主场景中的初始物品在某些生命周期节点仍然存在同时存档中的物品又被加载一遍。解决思路在加载前先统一clear_all_items()设置一个“已加载存档”标志防止_ready后再触发一次加载。8.5 Godot 3.x 与 4.x 的差异Godot 3.x 中 JSON 解析需要手动使用JSON.parse()并访问.result而 Godot 4.x 推荐直接使用JSON.parse_string()。如果你使用的是 Godot 3.x请将 load_world 修改为var json : JSON.new() var err : json.parse(content) if err OK: return json.data类型化字典是 Godot 4.1 以后逐步完善的特性在 Godot 3.x 中完全不会有效。因此建议新学习的朋友直接使用 Godot 4.x。9. 最佳实践与工程建议9.1 不要直接保存场景节点很多新手会想到把整个场景PackScene序列化保存或者把对象实例的所有属性都存起来。这是不可取的。更好的做法是只保存“数据状态”在加载时重新实例化节点这样可以最大程度避免节点引用失效、脚本版本更新后无法兼容的坑。9.2 给存档文件加上版本号在存档字典中加上version是强烈推荐的做法。将来你升级游戏时很容易根据版本号执行一次数据迁移if data.get(version, 0) 2: # 执行迁移逻辑 pass9.3 使用信号而不是跨脚本直接调用在 Item 中我们通过get_tree().current_scene找到主场景再调用其方法这种方式适合小项目。但如果项目变大建议改为信号在 Item 中定义signal remove_requested(item_node: Node)然后由主场景连接 Item 的remove_requested信号。这样 Item 不依赖主场景的名字或结构解耦更强。不过信号连接也需要在节点创建时完成因此我们在_ready中动态生成的 Item需要由父节点在生成时连接信号。具体代码如下在 main.gd 中func spawn_item_at(pos: Vector2, item_id: String ) - void: var item : ItemScene.instantiate() item.remove_requested.connect(_on_item_remove_requested) add_child(item) item.position pos if item_id : item.item_id item_%d % len(world_items) else: item.item_id item_id world_items.append(item) func _on_item_remove_requested(item_node: Node) - void: if item_node in world_items: world_items.erase(item_node) item_node.queue_free() save_game()Item 中改为signal remove_requested(item_node: Node) func _input_event(viewport: Node, event: InputEvent, shape_idx: int) - void: if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_RIGHT and event.pressed: remove_requested.emit(self)这个模式更接近真实项目也更符合观察者模式强烈建议在下一篇中改造过去。9.4 保存频率与性能如果场景里的物品数量达到几百上千每次右键删除都写入文件会造成磁盘压力。可以在真实项目中增加“自动保存间隔”普通变化只更新内存状态关键节点立即写盘一次定时自动保存每 30 秒或每 60 秒写一次盘。9.5 保证用户可见反馈在移动端或触屏环境没有鼠标右键建议把“移除工具”做成按钮切换后使用点击事件移除。如果做 PC 键鼠游戏可以在 UI 上提示“右键移除物体”。9.6 安全与合法范围本案例的移除操作只作用在游戏运行时创建的临时物体上不会修改项目工程文件或系统文件。如果把它集成到真实项目中要注意执行权限判断比如玩家是否可以移除该区域物体、是否属于当前副本避免访问未经授权的数据。特别是在做网络联机游戏时服务器必须校验移除请求而不能信任客户端单方面声明。10. 总结与下一步学习建议到这一步我们已经实现并解释了以下能力使用 Godot 4 的 FileAccess 与 JSON 完成世界存档使用类型化字典编写结构清晰的存档代码使用 Area2D 的_input_event实现右键点击移除物品在存档中保存物品的位置、ID、颜色在游戏启动时读取存档并恢复世界。你可以试着扩展这些方向增加“玩家角色”节点把真正角色的坐标、朝向、血量一并保存实现背包系统右键点击可拾取物品而不是直接删除做多个可切换场景之间的世界存档与跨场景恢复使用 Autoload 单例保存管理器避免每个场景都 new 一个 SaveManager给存档文件做加密或压缩不过要注意合法合规并且不要过度设计。类型化字典可能第一眼看上去只是多了几个类型标记但你写久了就会发现它真的能帮你规避大量手残打错键名的问题。配合类型化数组很多小错误在编辑器阶段就能被拦截这对刚学习 GDScript 的开发者非常友好。下一篇可以继续强化右键工具与背包拾取的结合也可以挑战跨场景存档。如果你在实际操作中遇到其它报错欢迎带着现象和日志信息继续交流。动手把代码从示例改写成自己的存档系统会比只看教程有效得多。
返回列表