第十八部分:存档系统基础:保存玩家等级、金币、背包、装备、任务、地图位置
这一部分非常关键。
前面我们已经做了很多系统:
玩家等级
经验值
金币
背包
装备
任务
地图位置
血量
商店
怪物掉落
如果没有存档,玩家一关游戏,所有东西全没了。
所以存档系统要解决的问题就是:
把玩家当前状态保存到硬盘
下次打开游戏时再恢复回来
这一部分先做最基础、最实用的版本:
保存玩家等级
保存经验
保存金币
保存血量
保存背包
保存装备
保存已接任务
保存已完成任务
保存当前地图
保存玩家位置
1. 存档系统到底保存什么
你不要把整个场景硬保存下来。
第一版应该保存“关键数据”。
比如:
玩家等级:3
当前经验:40
金币:120
当前血量:80
当前位置:Vector2(320, 180)
当前地图:forest_map
背包:
small_potion x3
slime_gel x8
装备:
weapon = wooden_sword
armor = leather_armor
任务:
active collect_slime_gel 3/5
completed first_talk_to_villager
也就是说,存档保存的是:
玩家状态
游戏进度
可恢复世界所需的核心信息
不是直接把所有节点都塞进文件里。
Godot 官方的“Saving games”教程也强调,存档前首先要识别哪些对象需要持久化,以及这些对象中哪些信息需要保留;官方示例会用 group 标记要保存的对象,但也说明可以用其他方式组织。(Godot Engine documentation)
2. 存档文件应该放在哪里
Godot 里常用:
user://
比如:
const SAVE_PATH := "user://save_01.dat"
user:// 指向用户设备上的应用数据目录,适合保存玩家存档、设置文件等。
Godot 的 FileAccess 文档说明,它可以在用户设备文件系统中永久存储和读取数据,并且适合保存游戏存档或玩家配置文件。(Godot Engine documentation)
不要保存到:
res://
因为 res:// 是项目资源路径,导出游戏后通常不适合写入运行时存档。
记住:
res:// 放游戏自带资源
user:// 放玩家运行后产生的数据
3. 存档方式选哪种
Godot 里常见几种存档方式:
FileAccess + Dictionary
JSON
ConfigFile
ResourceSaver / ResourceLoader
第一版我建议你用:
FileAccess + Dictionary + store_var / get_var
原因:
支持 Godot 常见数据类型
不用手动转 JSON 字符串
代码量不算大
比直接保存 Resource 更适合初学阶段理解
Godot 的 FileAccess 文档本身就是用于读写文件的类,并且官方示例也展示了写入和读取文件的基本方式。(Godot Engine documentation)
Resource 方式也能存档,ResourceSaver 是 Godot 用于把 Resource 保存到文件系统的单例,能保存 .tres、.res 等资源文件。(Godot Engine documentation) 但存档如果直接使用可加载 Resource,要更注意安全和结构边界;GDQuest 的 Godot 4 存档文章也提到 Resource 存档很方便,但加载来自不可信来源的 Resource 有代码执行风险,另一个选择是使用 FileAccess.store_var() / get_var() 做二进制序列化。(GDQuest)
所以第一版路线:
SaveManager
↓
收集数据成 Dictionary
↓
FileAccess.store_var(save_data)
↓
FileAccess.get_var()
↓
根据 Dictionary 恢复游戏
4. SaveManager 放在哪里
存档系统最好做成全局管理器。
也就是:
SaveManager Autoload
Godot 的 Autoload 可以让一个脚本或场景在游戏启动时自动加载,并在切换场景时一直存在;官方文档也说 Autoload 适合保存全局变量,例如玩家信息,也适合处理场景切换。(Godot Engine documentation)
操作路径:
Project
Project Settings
Globals
Autoload
添加 res://scripts/save/save_manager.gd
命名 SaveManager
这样任何地方都能调用:
SaveManager.save_game(...)
SaveManager.load_game(...)
不过你也要记住:Autoload 很方便,但别什么都往里面塞。
适合做 Autoload 的:
SaveManager
SceneManager
AudioManager
ItemDatabase
QuestManager
不适合随便塞进去的:
某个普通 NPC
某个宝箱
某只怪物
某个临时 UI
5. 第一版 SaveManager 结构
创建:
res://scripts/save/save_manager.gd
extends Node
const SAVE_PATH := "user://save_01.dat"
func save_game(player: Node, quest_manager: QuestManager, current_map_id: String):
pass
func load_game(player: Node, quest_manager: QuestManager):
pass
func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
这里我们先让外部传进来:
player
quest_manager
current_map_id
因为 SaveManager 不应该乱猜你的场景结构。
它只负责:
保存
读取
把数据交给对应系统恢复
6. 保存数据的整体结构
我们先设计最终保存出来的 Dictionary。
var save_data := {
"version": 1,
"current_map_id": "forest_map",
"player": {
"position": Vector2(320, 180),
"level": 3,
"current_exp": 40,
"exp_to_next_level": 200,
"current_hp": 80,
"base_attack": 14,
"base_defense": 2,
"base_max_hp": 120,
"gold": 150
},
"inventory": [
{
"item_id": "small_potion",
"quantity": 3
},
{
"item_id": "slime_gel",
"quantity": 8
}
],
"equipment": {
"weapon": "wooden_sword",
"armor": "leather_armor",
"accessory": ""
},
"quests": {
"active": [
{
"quest_id": "collect_slime_gel",
"status": 0,
"current_amount": 3
}
],
"completed": [
"first_talk_to_villager"
]
}
}
这个结构很清楚。
以后你调试存档时,一眼能看懂里面是什么。
7. 为什么要有 version
"version": 1
这个字段非常重要。
以后你的存档结构可能会变。
比如:
第一版没有天气
第二版加了 season
第三版加了 current_day
第四版加了 player_name
如果没有 version,你很难判断旧存档该怎么兼容。
第一版虽然暂时用不上,但建议从一开始就加。
这是个小习惯,后面能救命。
8. 保存玩家基础数据
SaveManager.gd:
func build_player_data(player: Node) -> Dictionary:
return {
"position": player.global_position,
"level": player.level,
"current_exp": player.current_exp,
"exp_to_next_level": player.exp_to_next_level,
"current_hp": player.current_hp,
"base_attack": player.base_attack,
"base_defense": player.base_defense,
"base_max_hp": player.base_max_hp,
"gold": player.gold
}
这里保存的是玩家本体数据。
注意:
装备加成后的 final_attack 不需要保存
final_defense 不需要保存
max_hp 不需要保存
为什么?
因为这些可以通过:
base 属性 + 装备
重新计算出来。
存档里尽量保存“源数据”,不要保存太多可计算结果。
否则容易出现:
装备说攻击 +5
存档里 final_attack 又写了 999
到底信谁?
第一版记住:
能算出来的数据,尽量不要存
9. 保存背包
Inventory 里现在大概有:
var slots: Array[InventorySlot] = []
每个 slot 有:
item
quantity
存档里不要直接存 item Resource。
存:
item.id
quantity
代码:
func build_inventory_data(inventory: Inventory) -> Array:
var data := []
for slot in inventory.slots:
if slot == null:
continue
if slot.item == null:
continue
data.append({
"item_id": slot.item.id,
"quantity": slot.quantity
})
return data
为什么不直接保存 ItemData?
因为以后加载时更清楚:
根据 item_id 去 ItemDatabase 找对应 ItemData
这也更适合后面做 JSON、云存档、多语言、版本迁移。
10. ItemDatabase 是什么
加载背包时,你只有:
small_potion
你需要根据 id 找回:
small_potion.tres
所以需要一个物品数据库。
第一版可以做成 Autoload:
ItemDatabase
创建:
res://scripts/items/item_database.gd
extends Node
@export var items: Array[ItemData] = []
var item_map: Dictionary = {}
func _ready():
rebuild()
func rebuild():
item_map.clear()
for item in items:
if item == null:
continue
if item.id == "":
push_warning("ItemData 有空 id:" + str(item))
continue
item_map[item.id] = item
func get_item(item_id: String) -> ItemData:
return item_map.get(item_id)
然后在 Project Settings 里把它设为 Autoload:
ItemDatabase
在 Inspector 里把这些资源拖进去:
small_potion.tres
gold_coin.tres
slime_gel.tres
wooden_sword.tres
leather_armor.tres
这样就能:
var item = ItemDatabase.get_item("small_potion")
11. 保存装备
装备也不要直接存 Resource。
存装备 ID。
Player 当前装备:
equipped_items[EquipmentData.SlotType.WEAPON]
equipped_items[EquipmentData.SlotType.ARMOR]
equipped_items[EquipmentData.SlotType.ACCESSORY]
保存:
func build_equipment_data(player: Node) -> Dictionary:
return {
"weapon": get_equipped_item_id(player, EquipmentData.SlotType.WEAPON),
"armor": get_equipped_item_id(player, EquipmentData.SlotType.ARMOR),
"accessory": get_equipped_item_id(player, EquipmentData.SlotType.ACCESSORY)
}
func get_equipped_item_id(player: Node, slot_type: EquipmentData.SlotType) -> String:
if not player.has_method("get_equipped_item"):
return ""
var item = player.get_equipped_item(slot_type)
if item == null:
return ""
return item.id
这样存档里很干净:
"equipment": {
"weapon": "wooden_sword",
"armor": "leather_armor",
"accessory": ""
}
12. 保存任务
QuestManager 里现在有:
active_quests: Dictionary
completed_quests: Dictionary
保存 active:
func build_active_quests_data(quest_manager: QuestManager) -> Array:
var data := []
for quest_id in quest_manager.active_quests.keys():
var quest_state: QuestState = quest_manager.active_quests[quest_id]
data.append({
"quest_id": quest_id,
"status": quest_state.status,
"current_amount": quest_state.current_amount
})
return data
保存 completed:
func build_completed_quests_data(quest_manager: QuestManager) -> Array:
var data := []
for quest_id in quest_manager.completed_quests.keys():
data.append(quest_id)
return data
整合:
func build_quests_data(quest_manager: QuestManager) -> Dictionary:
return {
"active": build_active_quests_data(quest_manager),
"completed": build_completed_quests_data(quest_manager)
}
13. QuestDatabase
和物品一样,加载任务时只有:
collect_slime_gel
你需要找到:
collect_slime_gel.tres
所以做一个 QuestDatabase。
res://scripts/quests/quest_database.gd
extends Node
@export var quests: Array[QuestData] = []
var quest_map: Dictionary = {}
func _ready():
rebuild()
func rebuild():
quest_map.clear()
for quest in quests:
if quest == null:
continue
if quest.id == "":
push_warning("QuestData 有空 id:" + str(quest))
continue
quest_map[quest.id] = quest
func get_quest(quest_id: String) -> QuestData:
return quest_map.get(quest_id)
设为 Autoload:
QuestDatabase
然后把:
collect_slime_gel.tres
拖进去。
14. 完整 save_game()
func save_game(player: Node, quest_manager: QuestManager, current_map_id: String) -> bool:
if player == null:
return false
if quest_manager == null:
return false
var inventory: Inventory = player.get_inventory()
var save_data := {
"version": 1,
"current_map_id": current_map_id,
"player": build_player_data(player),
"inventory": build_inventory_data(inventory),
"equipment": build_equipment_data(player),
"quests": build_quests_data(quest_manager)
}
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("无法打开存档文件进行写入:" + SAVE_PATH)
return false
file.store_var(save_data)
file.close()
print("存档成功:", SAVE_PATH)
return true
这个函数做了:
检查参数
收集玩家数据
收集背包数据
收集装备数据
收集任务数据
打开文件
写入数据
关闭文件
15. 加载存档的整体流程
加载时流程反过来:
读取文件
拿到 save_data
恢复当前地图
恢复玩家基础数据
恢复背包
恢复装备
恢复任务
刷新 UI
注意:
如果存档中的地图不是当前地图
应该先切换地图,再恢复玩家位置
第一版我们可以先假设:
当前地图就是存档地图
后面再接 SceneManager。
16. load_game()
func load_game(player: Node, quest_manager: QuestManager) -> bool:
if not FileAccess.file_exists(SAVE_PATH):
print("没有存档")
return false
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("无法打开存档文件进行读取:" + SAVE_PATH)
return false
var save_data = file.get_var()
file.close()
if typeof(save_data) != TYPE_DICTIONARY:
push_error("存档格式错误")
return false
apply_save_data(save_data, player, quest_manager)
print("读档成功")
return true
17. apply_save_data()
func apply_save_data(save_data: Dictionary, player: Node, quest_manager: QuestManager):
if player == null:
return
if quest_manager == null:
return
apply_player_data(save_data.get("player", {}), player)
apply_inventory_data(save_data.get("inventory", []), player.get_inventory())
apply_equipment_data(save_data.get("equipment", {}), player)
apply_quests_data(save_data.get("quests", {}), quest_manager)
if player.has_method("recalculate_stats"):
player.recalculate_stats()
if player.has_signal("health_changed"):
player.health_changed.emit(player.current_hp, player.max_hp)
if player.has_signal("gold_changed"):
player.gold_changed.emit(player.gold)
if player.has_signal("experience_changed"):
player.experience_changed.emit(player.current_exp, player.exp_to_next_level)
if player.has_signal("level_changed"):
player.level_changed.emit(player.level)
if player.has_signal("equipment_changed"):
player.equipment_changed.emit()
这里最后手动 emit 是为了让 UI 立刻刷新。
18. 恢复玩家数据
func apply_player_data(data: Dictionary, player: Node):
player.global_position = data.get("position", player.global_position)
player.level = data.get("level", player.level)
player.current_exp = data.get("current_exp", player.current_exp)
player.exp_to_next_level = data.get("exp_to_next_level", player.exp_to_next_level)
player.base_attack = data.get("base_attack", player.base_attack)
player.base_defense = data.get("base_defense", player.base_defense)
player.base_max_hp = data.get("base_max_hp", player.base_max_hp)
player.gold = data.get("gold", player.gold)
player.current_hp = data.get("current_hp", player.current_hp)
这里用 data.get(key, default)。
好处:
旧存档缺字段时,不会直接报错
比如第一版存档没有 base_defense,第二版读档时会用当前默认值。
19. 恢复背包
恢复背包前,先清空当前背包。
Inventory.gd 建议加:
func clear():
slots.clear()
inventory_changed.emit()
SaveManager:
func apply_inventory_data(data: Array, inventory: Inventory):
inventory.slots.clear()
for entry in data:
if typeof(entry) != TYPE_DICTIONARY:
continue
var item_id: String = entry.get("item_id", "")
var quantity: int = entry.get("quantity", 0)
if item_id == "" or quantity <= 0:
continue
var item: ItemData = ItemDatabase.get_item(item_id)
if item == null:
push_warning("找不到物品:" + item_id)
continue
inventory.add_item(item, quantity)
inventory.inventory_changed.emit()
这里依赖:
ItemDatabase
所以 ItemDatabase 必须是 Autoload,并且 items 列表里要有所有可保存物品。
20. 恢复装备
func apply_equipment_data(data: Dictionary, player: Node):
if not player.has_method("equip_item"):
return
var weapon_id: String = data.get("weapon", "")
var armor_id: String = data.get("armor", "")
var accessory_id: String = data.get("accessory", "")
equip_by_id(player, weapon_id)
equip_by_id(player, armor_id)
equip_by_id(player, accessory_id)
func equip_by_id(player: Node, item_id: String):
if item_id == "":
return
var item: ItemData = ItemDatabase.get_item(item_id)
if item == null:
push_warning("找不到装备:" + item_id)
return
player.equip_item(item)
注意:
恢复装备前最好先恢复背包
因为装备通常应该存在于背包中。
不过当前第一版装备只是引用 ItemData,所以先恢复装备也能跑。
但推荐顺序:
先背包
再装备
21. 恢复任务
加载 active quests:
func apply_quests_data(data: Dictionary, quest_manager: QuestManager):
quest_manager.active_quests.clear()
quest_manager.completed_quests.clear()
var active: Array = data.get("active", [])
var completed: Array = data.get("completed", [])
for entry in active:
if typeof(entry) != TYPE_DICTIONARY:
continue
var quest_id: String = entry.get("quest_id", "")
var quest_data: QuestData = QuestDatabase.get_quest(quest_id)
if quest_data == null:
push_warning("找不到任务:" + quest_id)
continue
var quest_state := QuestState.new(quest_data)
quest_state.status = entry.get("status", QuestState.Status.IN_PROGRESS)
quest_state.current_amount = entry.get("current_amount", 0)
quest_manager.active_quests[quest_id] = quest_state
for quest_id in completed:
var quest_data: QuestData = QuestDatabase.get_quest(quest_id)
if quest_data == null:
push_warning("找不到已完成任务:" + str(quest_id))
continue
var quest_state := QuestState.new(quest_data)
quest_state.status = QuestState.Status.COMPLETED
quest_manager.completed_quests[quest_id] = quest_state
最后可以刷新任务 UI:
for quest_state in quest_manager.active_quests.values():
quest_manager.quest_updated.emit(quest_state)
但更干净的是 QuestPanel 打开时自己 refresh。
第一版可以在加载后统一发信号:
func emit_all_quest_updates(quest_manager: QuestManager):
for quest_state in quest_manager.active_quests.values():
quest_manager.quest_updated.emit(quest_state)
for quest_state in quest_manager.completed_quests.values():
quest_manager.quest_completed.emit(quest_state)
22. SaveManager 完整基础版
这份比较长,但能作为第一版骨架。
extends Node
const SAVE_PATH := "user://save_01.dat"
func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
func save_game(player: Node, quest_manager: QuestManager, current_map_id: String) -> bool:
if player == null:
return false
if quest_manager == null:
return false
var inventory: Inventory = player.get_inventory()
var save_data := {
"version": 1,
"current_map_id": current_map_id,
"player": build_player_data(player),
"inventory": build_inventory_data(inventory),
"equipment": build_equipment_data(player),
"quests": build_quests_data(quest_manager)
}
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("无法打开存档文件进行写入:" + SAVE_PATH)
return false
file.store_var(save_data)
file.close()
print("存档成功:", SAVE_PATH)
return true
func load_game(player: Node, quest_manager: QuestManager) -> bool:
if not FileAccess.file_exists(SAVE_PATH):
print("没有存档")
return false
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("无法打开存档文件进行读取:" + SAVE_PATH)
return false
var save_data = file.get_var()
file.close()
if typeof(save_data) != TYPE_DICTIONARY:
push_error("存档格式错误")
return false
apply_save_data(save_data, player, quest_manager)
print("读档成功")
return true
func build_player_data(player: Node) -> Dictionary:
return {
"position": player.global_position,
"level": player.level,
"current_exp": player.current_exp,
"exp_to_next_level": player.exp_to_next_level,
"current_hp": player.current_hp,
"base_attack": player.base_attack,
"base_defense": player.base_defense,
"base_max_hp": player.base_max_hp,
"gold": player.gold
}
func build_inventory_data(inventory: Inventory) -> Array:
var data := []
for slot in inventory.slots:
if slot == null:
continue
if slot.item == null:
continue
data.append({
"item_id": slot.item.id,
"quantity": slot.quantity
})
return data
func build_equipment_data(player: Node) -> Dictionary:
return {
"weapon": get_equipped_item_id(player, EquipmentData.SlotType.WEAPON),
"armor": get_equipped_item_id(player, EquipmentData.SlotType.ARMOR),
"accessory": get_equipped_item_id(player, EquipmentData.SlotType.ACCESSORY)
}
func get_equipped_item_id(player: Node, slot_type: EquipmentData.SlotType) -> String:
if not player.has_method("get_equipped_item"):
return ""
var item = player.get_equipped_item(slot_type)
if item == null:
return ""
return item.id
func build_quests_data(quest_manager: QuestManager) -> Dictionary:
return {
"active": build_active_quests_data(quest_manager),
"completed": build_completed_quests_data(quest_manager)
}
func build_active_quests_data(quest_manager: QuestManager) -> Array:
var data := []
for quest_id in quest_manager.active_quests.keys():
var quest_state: QuestState = quest_manager.active_quests[quest_id]
data.append({
"quest_id": quest_id,
"status": quest_state.status,
"current_amount": quest_state.current_amount
})
return data
func build_completed_quests_data(quest_manager: QuestManager) -> Array:
var data := []
for quest_id in quest_manager.completed_quests.keys():
data.append(quest_id)
return data
func apply_save_data(save_data: Dictionary, player: Node, quest_manager: QuestManager):
apply_player_data(save_data.get("player", {}), player)
apply_inventory_data(save_data.get("inventory", []), player.get_inventory())
apply_equipment_data(save_data.get("equipment", {}), player)
apply_quests_data(save_data.get("quests", {}), quest_manager)
if player.has_method("recalculate_stats"):
player.recalculate_stats()
player.health_changed.emit(player.current_hp, player.max_hp)
player.gold_changed.emit(player.gold)
player.experience_changed.emit(player.current_exp, player.exp_to_next_level)
player.level_changed.emit(player.level)
player.equipment_changed.emit()
func apply_player_data(data: Dictionary, player: Node):
player.global_position = data.get("position", player.global_position)
player.level = data.get("level", player.level)
player.current_exp = data.get("current_exp", player.current_exp)
player.exp_to_next_level = data.get("exp_to_next_level", player.exp_to_next_level)
player.base_attack = data.get("base_attack", player.base_attack)
player.base_defense = data.get("base_defense", player.base_defense)
player.base_max_hp = data.get("base_max_hp", player.base_max_hp)
player.gold = data.get("gold", player.gold)
player.current_hp = data.get("current_hp", player.current_hp)
func apply_inventory_data(data: Array, inventory: Inventory):
inventory.slots.clear()
for entry in data:
if typeof(entry) != TYPE_DICTIONARY:
continue
var item_id: String = entry.get("item_id", "")
var quantity: int = entry.get("quantity", 0)
if item_id == "" or quantity <= 0:
continue
var item: ItemData = ItemDatabase.get_item(item_id)
if item == null:
push_warning("找不到物品:" + item_id)
continue
inventory.add_item(item, quantity)
inventory.inventory_changed.emit()
func apply_equipment_data(data: Dictionary, player: Node):
equip_by_id(player, data.get("weapon", ""))
equip_by_id(player, data.get("armor", ""))
equip_by_id(player, data.get("accessory", ""))
func equip_by_id(player: Node, item_id: String):
if item_id == "":
return
var item: ItemData = ItemDatabase.get_item(item_id)
if item == null:
push_warning("找不到装备:" + item_id)
return
player.equip_item(item)
func apply_quests_data(data: Dictionary, quest_manager: QuestManager):
quest_manager.active_quests.clear()
quest_manager.completed_quests.clear()
var active: Array = data.get("active", [])
var completed: Array = data.get("completed", [])
for entry in active:
if typeof(entry) != TYPE_DICTIONARY:
continue
var quest_id: String = entry.get("quest_id", "")
var quest_data: QuestData = QuestDatabase.get_quest(quest_id)
if quest_data == null:
push_warning("找不到任务:" + quest_id)
continue
var quest_state := QuestState.new(quest_data)
quest_state.status = entry.get("status", QuestState.Status.IN_PROGRESS)
quest_state.current_amount = entry.get("current_amount", 0)
quest_manager.active_quests[quest_id] = quest_state
for quest_id in completed:
var quest_data: QuestData = QuestDatabase.get_quest(str(quest_id))
if quest_data == null:
push_warning("找不到已完成任务:" + str(quest_id))
continue
var quest_state := QuestState.new(quest_data)
quest_state.status = QuestState.Status.COMPLETED
quest_manager.completed_quests[str(quest_id)] = quest_state
23. Player 需要提供哪些方法
为了让 SaveManager 工作,Player 至少要有:
func get_inventory() -> Inventory:
return inventory
还要有这些字段或信号:
global_position
level
current_exp
exp_to_next_level
current_hp
base_attack
base_defense
base_max_hp
gold
health_changed
gold_changed
experience_changed
level_changed
equipment_changed
recalculate_stats()
get_equipped_item()
equip_item()
如果某个字段你还没做,就先删掉对应保存逻辑。
不要为了存档硬塞还没实现的系统。
24. Inventory 需要注意的小问题
apply_inventory_data() 里会调用:
inventory.add_item(item, quantity)
而 add_item() 会 emit inventory_changed。
循环中可能 emit 很多次。
第一版没事。
后面可以优化:
批量恢复时先不发信号
全部恢复完再发一次
比如:
inventory.set_items_from_save(data)
现在不用急。
25. 保存 / 读取快捷键
InputMap 添加:
save_game
load_game
绑定:
F5 保存
F9 读取
Main.gd:
extends Node
@onready var player: Node = $World/Player
@onready var quest_manager: QuestManager = $QuestManager
@export var current_map_id: String = "forest_map"
func _unhandled_input(event):
if event.is_action_pressed("save_game"):
SaveManager.save_game(player, quest_manager, current_map_id)
get_viewport().set_input_as_handled()
if event.is_action_pressed("load_game"):
SaveManager.load_game(player, quest_manager)
get_viewport().set_input_as_handled()
现在按 F5 保存,按 F9 读取。
第一版非常适合测试。
26. 正式游戏不一定用 F5/F9
F5/F9 只是开发调试方便。
正式游戏可能是:
暂停菜单
存档按钮
读档按钮
自动存档
进入新地图自动存档
睡觉时存档
检查点存档
现在先用快捷键。
它像开发者作弊码,丑但好用。
27. 保存当前地图 ID
我们前面保存了:
"current_map_id": current_map_id
但现在 load_game() 还没有处理切地图。
第一版先忽略地图切换。
后面你有 SceneManager 后,可以这样:
读档
↓
拿到 current_map_id
↓
SceneManager 切到对应地图
↓
地图加载完成
↓
恢复玩家位置和数据
这是下一阶段内容。
当前先在同一张地图测试存档。
28. 多存档位
第一版只有:
const SAVE_PATH := "user://save_01.dat"
以后多存档位可以变成:
func get_save_path(slot: int) -> String:
return "user://save_%d.dat" % slot
然后:
save_game(slot, ...)
load_game(slot, ...)
比如:
save_1.dat
save_2.dat
save_3.dat
第一版先不做。
29. 自动存档
以后你可以在这些时机自动保存:
完成任务
进入新地图
睡觉
过剧情节点
击败 Boss
退出游戏前
但不要一开始就自动存档。
先手动 F5/F9 测通。
自动存档如果写错,会非常吓人:你刚把数据弄坏,它还贴心地帮你保存了。简直是数字世界的“帮倒忙冠军”。
30. 存档和 Resource 的安全提醒
你之前用 Resource 做了很多数据:
ItemData
QuestData
ShopData
EquipmentData
这些是游戏自带资源,没问题。
但玩家存档如果直接保存成 Resource 并从外部加载,就要注意安全边界。
GDQuest 的 Godot 4 存档文章提到,Resource 存档方便且支持 Godot 类型,但如果加载来自不可信来源的 Resource,存在执行代码的风险;它也提到 FileAccess.store_var() / get_var() 默认不保存对象,因此能避免这类代码执行问题。(GDQuest)
所以第一版我们用:
FileAccess.store_var(Dictionary)
是比较稳的路线。
31. JSON 可以用吗
可以,但我不建议你第一版用 JSON。
原因:
JSON 不原生支持 Vector2
不区分一些 Godot 类型
需要手动转来转去
代码会多
比如 Vector2 要转:
{
"x": player.global_position.x,
"y": player.global_position.y
}
读取时再:
Vector2(data["x"], data["y"])
这对前端开发者很熟悉,但在 Godot 存本地数据时不一定最省事。
如果你未来要做:
云存档
后端同步
Web 管理工具
跨语言读取
那 JSON 会有价值。
但本地单机 RPG 第一版:
FileAccess.store_var(Dictionary)
更快。
32. 存档调试技巧
你可以在保存前打印:
print(save_data)
比如:
print("Save data: ", save_data)
如果数据太长,可以分段打印:
print("Player: ", save_data["player"])
print("Inventory: ", save_data["inventory"])
print("Equipment: ", save_data["equipment"])
print("Quests: ", save_data["quests"])
读档后也打印:
print("Loaded data: ", save_data)
存档系统最怕“默默失败”。
一定要多打印。
33. 删除存档
SaveManager 可以加:
func delete_save() -> bool:
if not FileAccess.file_exists(SAVE_PATH):
return false
var error = DirAccess.remove_absolute(ProjectSettings.globalize_path(SAVE_PATH))
if error != OK:
push_error("删除存档失败")
return false
print("存档已删除")
return true
不过第一版不一定要做。
如果你测试时想清空存档,也可以去用户数据目录手动删。
34. 存档失败常见原因
常见问题:
SAVE_PATH 写错
FileAccess.open 返回 null
写入对象里有无法序列化的内容
ItemDatabase 没有对应物品
QuestDatabase 没有对应任务
读档时字段名写错
加载顺序不对
UI 没刷新
第一版建议:
先只保存金币和位置
成功后再加背包
再加装备
再加任务
不要第一次就全部塞进去。
否则报错时你不知道是谁炸了。
35. 推荐开发顺序
你可以按这个顺序做:
第一步:保存/读取金币
第二步:保存/读取位置
第三步:保存/读取等级和经验
第四步:保存/读取背包
第五步:保存/读取装备
第六步:保存/读取任务
第七步:保存/读取地图 ID
这样每一步都能验证。
不要一步到位。
一步到位很酷,一步炸完也很酷,就是人不太酷。
36. 最小测试:只存金币和位置
你可以先做最小版。
SaveManager:
func save_game_minimal(player: Node):
var save_data := {
"gold": player.gold,
"position": player.global_position
}
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
file.store_var(save_data)
file.close()
func load_game_minimal(player: Node):
if not FileAccess.file_exists(SAVE_PATH):
return
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
var save_data = file.get_var()
file.close()
player.gold = save_data.get("gold", 0)
player.global_position = save_data.get("position", player.global_position)
player.gold_changed.emit(player.gold)
先把这个跑通。
再慢慢扩展。
37. 存档和 UI 刷新
读档后数据恢复了,但 UI 不一定自动刷新。
所以读档后一定要发信号:
player.health_changed.emit(player.current_hp, player.max_hp)
player.gold_changed.emit(player.gold)
player.experience_changed.emit(player.current_exp, player.exp_to_next_level)
player.level_changed.emit(player.level)
player.equipment_changed.emit()
inventory.inventory_changed.emit()
任务也一样:
quest_manager.quest_updated.emit(quest_state)
你要记住:
数据变了
UI 不会自动知道
除非你通知它
38. 存档和 current_hp 的细节
读档时最好先恢复:
等级
基础属性
装备
max_hp
current_hp
但我们的代码流程是:
apply_player_data 恢复 current_hp
apply_inventory_data
apply_equipment_data
recalculate_stats
recalculate_stats() 里会:
current_hp = min(current_hp, max_hp)
这很好。
如果读档里 current_hp = 150,但装备恢复后 max_hp = 120,就会自动压到 120。
39. 装备恢复和血量恢复顺序
如果装备加最大生命:
皮甲 max_hp +20
读档顺序就会影响结果。
推荐:
恢复基础属性
恢复背包
恢复装备
recalculate_stats
恢复 current_hp
emit health_changed
我们前面的版本是先恢复 current_hp,再 recalculate_stats。
大多数情况下没问题,因为会 min 一次。
更严谨可以改成:
var saved_current_hp = data.get("current_hp", player.current_hp)
...
恢复装备后
player.recalculate_stats()
player.current_hp = clamp(saved_current_hp, 0, player.max_hp)
第一版不用太纠结。
但你要知道这个细节。
40. 更严谨的 apply_save_data 顺序
可以改成:
func apply_save_data(save_data: Dictionary, player: Node, quest_manager: QuestManager):
var player_data: Dictionary = save_data.get("player", {})
var saved_current_hp: int = player_data.get("current_hp", player.current_hp)
apply_player_data_without_current_hp(player_data, player)
apply_inventory_data(save_data.get("inventory", []), player.get_inventory())
apply_equipment_data(save_data.get("equipment", {}), player)
player.recalculate_stats()
player.current_hp = clamp(saved_current_hp, 0, player.max_hp)
apply_quests_data(save_data.get("quests", {}), quest_manager)
emit_player_refresh_signals(player)
这会更稳。
第一版如果你能理解这个就很好,代码可以先不重构。
41. 存档和地图对象
当前我们还没保存:
哪些宝箱已经打开
哪些怪物已经死亡
哪些采集点已经被采集
地图上的掉落物还在不在
NPC 位置变化
这些属于“世界状态存档”。
第一版可以先不做。
否则复杂度会一下子爆炸。
你现在先保存玩家相关数据。
等游戏有稳定地图后,再做:
WorldState
MapState
ChestState
EnemyState
42. 宝箱打开状态以后怎么保存
每个宝箱需要 id:
@export var chest_id: String = "forest_chest_001"
存档保存:
opened_chests = ["forest_chest_001", "forest_chest_002"]
加载地图时:
如果 chest_id 在 opened_chests 里
这个宝箱显示打开状态
不能再次领取
现在先不做。
但这个模式以后很重要:
世界对象要保存,就必须有稳定 ID
43. 怪物死亡状态以后怎么保存
普通小怪通常不保存死亡状态。
因为玩家切地图回来,怪物可以刷新。
Boss 或精英怪可能要保存:
defeated_bosses = ["forest_boss"]
这和宝箱一样:
稳定 ID
存已完成状态
加载时应用
44. 当前推荐节点和管理器结构
现在项目大概是:
Autoload
├── SaveManager
├── ItemDatabase
└── QuestDatabase
Main
├── QuestManager
├── World
│ ├── Player
│ ├── Enemies
│ ├── Interactables
│ └── Drops
└── UI
以后可能变成:
Autoload
├── SaveManager
├── SceneManager
├── ItemDatabase
├── QuestDatabase
└── AudioManager
QuestManager 是否 Autoload,看你项目结构。
如果任务状态要跨地图一直存在,后面可以让 QuestManager 也变成 Autoload。
45. 常见错误 1:FileAccess.open 返回 null
排查:
SAVE_PATH 是否写错
是否用了 res:// 写入
文件夹是否存在
权限是否正常
第一版用:
"user://save_01.dat"
最省事。
46. 常见错误 2:读档后背包空了
排查:
save_data["inventory"] 是否真的有数据
ItemDatabase 是否 Autoload
ItemDatabase.items 是否拖了物品资源
ItemData.id 是否和存档 item_id 一致
apply_inventory_data 是否被调用
inventory.inventory_changed 是否 emit
最常见的是:
ItemDatabase 里忘记拖 small_potion.tres
47. 常见错误 3:读档后装备没了
排查:
equipment 里是否保存了 weapon/armor
ItemDatabase 是否有对应 EquipmentData
equip_by_id 是否找到 item
Player.equip_item 是否成功
装备资源 item_type 是否是 EQUIPMENT
装备资源是否是 EquipmentData
如果装备资源是普通 ItemData,不是 EquipmentData,equip_item() 会失败。
48. 常见错误 4:读档后 UI 没刷新
排查:
读档后是否 emit health_changed
是否 emit gold_changed
是否 emit experience_changed
是否 emit level_changed
是否 emit equipment_changed
Inventory 是否 emit inventory_changed
QuestPanel 是否 refresh
数据恢复了,不代表 UI 自动刷新。
这是 Godot 新手很容易踩的坑。
49. 常见错误 5:任务读档后 NPC 状态不对
排查:
QuestDatabase 是否有任务资源
completed_quests 是否恢复
active_quests 是否恢复
QuestData.id 是否一致
NPC.quest 是否同一个任务
NPC.quest_manager 是否正确
如果任务 ID 空了,一切都会乱。
任务和物品一样:
id 绝对不能随便改
50. 常见错误 6:读档后位置不对
排查:
player.global_position 是否被恢复
读档后是否又被 World.gd 设置回 PlayerSpawn
是否在 _ready() 里覆盖了位置
是否当前地图和存档地图不一致
这个非常常见。
比如 World.gd 里有:
player.global_position = player_spawn.global_position
然后你读档恢复位置,结果下一帧又被出生点覆盖。
解决:
如果是读档进入地图,就不要再套默认出生点
后面 SceneManager 章节会处理得更完整。
51. 这一部分最重要的记忆点
存档保存的是关键数据,不是整个场景
user:// 适合保存玩家运行时数据
第一版推荐 FileAccess + Dictionary + store_var/get_var
SaveManager 适合做 Autoload
物品和任务不要直接存 Resource,存 id
加载时通过 ItemDatabase / QuestDatabase 找回资源
能计算出来的 final 属性不要存,存 base 属性和装备
读档后要重新发信号刷新 UI
地图对象状态以后再做,不要第一版就全塞进去
52. 你现在的小练习
按这个顺序来做:
1. 创建 SaveManager.gd
2. 添加 Autoload:SaveManager
3. 创建 ItemDatabase.gd
4. 添加 Autoload:ItemDatabase
5. 把 small_potion、slime_gel、wooden_sword、leather_armor 拖进 ItemDatabase
6. 创建 QuestDatabase.gd
7. 添加 Autoload:QuestDatabase
8. 把 collect_slime_gel 拖进 QuestDatabase
9. Player 添加 get_inventory()
10. Main.gd 里绑定 F5 保存
11. Main.gd 里绑定 F9 读取
12. 第一版只保存 gold 和 position
13. 测试成功后保存 level / exp / hp
14. 再保存 inventory
15. 再保存 equipment
16. 再保存 active/completed quests
17. 改金币、移动位置、捡药水、装备木剑、接任务
18. 按 F5 保存
19. 重启游戏
20. 按 F9 读取
21. 检查金币、位置、背包、装备、任务是否恢复
完成这一部分后,你的 RPG 原型就有了最重要的“长期进度保存”能力。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录