第十八部分:存档系统基础:保存玩家等级、金币、背包、装备、任务、地图位置

2026-05-19
471316 分钟
...

这一部分非常关键。

前面我们已经做了很多系统:

玩家等级
经验值
金币
背包
装备
任务
地图位置
血量
商店
怪物掉落

如果没有存档,玩家一关游戏,所有东西全没了。

所以存档系统要解决的问题就是:

把玩家当前状态保存到硬盘
下次打开游戏时再恢复回来

这一部分先做最基础、最实用的版本:

保存玩家等级
保存经验
保存金币
保存血量
保存背包
保存装备
保存已接任务
保存已完成任务
保存当前地图
保存玩家位置

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. 添加 AutoloadSaveManager
3. 创建 ItemDatabase.gd
4. 添加 AutoloadItemDatabase
5. small_potionslime_gelwooden_swordleather_armor 拖进 ItemDatabase
6. 创建 QuestDatabase.gd
7. 添加 AutoloadQuestDatabase
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 原型就有了最重要的“长期进度保存”能力。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...