第二十部分:地图状态保存:宝箱已打开、Boss 已击败、机关已触发、采集点刷新

2026-05-19
404714 分钟
...

上一部分我们已经能做到:

切换地图
室内外传送
读档进入指定地图
保存玩家位置
保存当前地图 ID

但现在还有一个大问题:

玩家打开了森林里的宝箱
切到村庄
再回来
宝箱是不是又能开一次

玩家击败了 Boss
保存读档
Boss 是不是又复活了

玩家触发了机关
切地图回来
机关状态是不是丢了

这就是“地图状态保存”要解决的问题。

1. 什么是地图状态

地图状态就是:

某一张地图里哪些东西已经发生过变化

比如:

宝箱是否已打开
Boss 是否已击败
机关是否已触发
门是否已解锁
采集点是否已采集
剧情道具是否已拿走
某个 NPC 是否已经离开
某个障碍是否已经被清除

这些状态和玩家背包、等级不一样。

玩家数据属于:

Player Save

地图变化属于:

World / Map Save

也就是:

玩家身上的东西

世界里发生过的事
要分开保存

2. 第一版地图状态先保存什么

第一版建议先做这几类:

1. 宝箱已打开
2. Boss 已击败
3. 机关已触发
4. 可采集物已采集

先不要做:

普通小怪死亡状态
普通掉落物状态
每个 NPC 的精细位置
所有地图物体的完整属性
复杂时间刷新

普通小怪可以切地图刷新。

普通掉落物也可以切地图消失。

第一版重点保存“重要且不可重复领取”的东西。

3. 为什么每个地图对象都需要 persistent_id

要保存一个对象的状态,必须能稳定识别它。

比如森林里有三个宝箱:

forest_chest_001
forest_chest_002
forest_chest_003

存档里保存:

forest_chest_001 已打开
forest_chest_003 已打开

下次读档时,地图加载后,每个宝箱检查自己的 ID:

我的 ID forest_chest_001
存档说我已经打开
那我就显示打开状态不能再领奖励

所以任何需要保存状态的地图对象,都应该有:

@export var persistent_id: String = ""

这个 ID 一旦游戏发布后,就不要随便改。

它类似数据库里的主键。

4. persistent_id 命名建议

推荐包含地图名和对象类型:

forest_chest_001
forest_chest_002

forest_boss_slime_king

village_gate_switch_001

forest_herb_node_001
forest_herb_node_002

dungeon_door_red_key

不要写:

chest1
abc
test
box

现在看起来省事,后面地图多了会变成谜语大会。

好的 ID 应该一眼看出:

在哪张地图
是什么对象
大概是哪一个

5. 地图状态保存在哪里

可以在 SaveManager 里加一个字段:

var world_state: Dictionary = {}

结构大概这样:

{
    "opened_chests": {
        "forest_chest_001": true,
        "forest_chest_002": true
    },
    "defeated_enemies": {
        "forest_boss_slime_king": true
    },
    "activated_switches": {
        "village_gate_switch_001": true
    },
    "collected_nodes": {
        "forest_herb_node_001": true
    }
}

也可以按地图分组:

{
    "maps": {
        "forest": {
            "opened_chests": ["forest_chest_001"],
            "defeated_enemies": ["forest_boss_slime_king"],
            "collected_nodes": ["forest_herb_node_001"]
        },
        "village": {
            "activated_switches": ["village_gate_switch_001"]
        }
    }
}

第一版我建议用全局分类字典:

opened_chests
defeated_enemies
activated_switches
collected_nodes

简单直接。

6. SaveManager 添加 world_state

SaveManager.gd 里加:

var world_state: Dictionary = {
    "opened_chests": {},
    "defeated_enemies": {},
    "activated_switches": {},
    "collected_nodes": {}
}

然后写几个通用方法:

func mark_state(category: String, persistent_id: String):
    if persistent_id == "":
        return

    if not world_state.has(category):
        world_state[category] = {}

    world_state[category][persistent_id] = true

func has_state(category: String, persistent_id: String) -> bool:
    if persistent_id == "":
        return false

    if not world_state.has(category):
        return false

    return world_state[category].has(persistent_id)

func clear_world_state():
    world_state = {
        "opened_chests": {},
        "defeated_enemies": {},
        "activated_switches": {},
        "collected_nodes": {}
    }

这样任何地图对象都可以问:

SaveManager.has_state("opened_chests", persistent_id)

或者标记:

SaveManager.mark_state("opened_chests", persistent_id)

7. 为什么用 Dictionary 而不是 Array

也可以这样存:

"opened_chests": ["forest_chest_001", "forest_chest_002"]

但 Dictionary 查询更方便:

world_state["opened_chests"].has("forest_chest_001")

如果用 Array,你要:

opened_chests.has("forest_chest_001")

也能用。

但 Dictionary 更适合表示:

这个 ID 是否已经发生过

所以第一版用:

{
    "forest_chest_001": true
}

很舒服。

8. 保存 world_state

上一节 SaveManager 的 save_data 里加一项:

"world_state": world_state

完整片段:

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),
    "world_state": world_state
}

FileAccess 适合把游戏存档、玩家配置这类数据写入用户设备文件系统;这里继续沿用上一节的 FileAccess.store_var() / get_var() 思路即可。(Godot Engine documentation)

9. 读取 world_state

apply_save_data() 里加:

world_state = save_data.get("world_state", {
    "opened_chests": {},
    "defeated_enemies": {},
    "activated_switches": {},
    "collected_nodes": {}
})

建议封装一下:

func apply_world_state_data(data: Dictionary):
    world_state = {
        "opened_chests": data.get("opened_chests", {}),
        "defeated_enemies": data.get("defeated_enemies", {}),
        "activated_switches": data.get("activated_switches", {}),
        "collected_nodes": data.get("collected_nodes", {})
    }

然后:

apply_world_state_data(save_data.get("world_state", {}))

这样旧存档没有 world_state 时也不会炸。

10. SaveManager 世界状态完整片段

var world_state: Dictionary = {
    "opened_chests": {},
    "defeated_enemies": {},
    "activated_switches": {},
    "collected_nodes": {}
}

func mark_state(category: String, persistent_id: String):
    if persistent_id == "":
        return

    if not world_state.has(category):
        world_state[category] = {}

    world_state[category][persistent_id] = true

func has_state(category: String, persistent_id: String) -> bool:
    if persistent_id == "":
        return false

    if not world_state.has(category):
        return false

    return world_state[category].has(persistent_id)

func clear_world_state():
    world_state = {
        "opened_chests": {},
        "defeated_enemies": {},
        "activated_switches": {},
        "collected_nodes": {}
    }

func build_world_state_data() -> Dictionary:
    return world_state.duplicate(true)

func apply_world_state_data(data: Dictionary):
    world_state = {
        "opened_chests": data.get("opened_chests", {}),
        "defeated_enemies": data.get("defeated_enemies", {}),
        "activated_switches": data.get("activated_switches", {}),
        "collected_nodes": data.get("collected_nodes", {})
    }

注意:

duplicate(true)

表示深拷贝。

这样存档数据不会直接引用原来的 world_state 内部对象。

11. 宝箱状态保存

现在改造 Chest。

结构:

Chest Area2D
├── Sprite2D
└── CollisionShape2D

Chest.gd:

extends Area2D

@export var persistent_id: String = ""
@export var prompt_text: String = "按 E 打开"

@export var item: ItemData
@export var quantity: int = 1

var opened: bool = false

@onready var sprite: Sprite2D = $Sprite2D

func _ready():
    apply_saved_state()

func apply_saved_state():
    if SaveManager.has_state("opened_chests", persistent_id):
        opened = true
        update_visual()

func interact(player: Node):
    if opened:
        print("宝箱已经打开")
        return

    opened = true

    if player.has_method("pick_up_item"):
        player.pick_up_item(item, quantity)

    SaveManager.mark_state("opened_chests", persistent_id)

    update_visual()

func update_visual():
    if opened:
        # 这里可以换成打开后的宝箱图片
        # sprite.texture = opened_texture
        sprite.modulate = Color(0.6, 0.6, 0.6)
    else:
        sprite.modulate = Color(1, 1, 1)

第一版如果没有打开后的宝箱图片,可以先让它变暗。

等美术有了再换贴图。

12. 宝箱为什么在 _ready() 里 apply_saved_state

地图加载时,宝箱节点会创建。

这时它要立刻问 SaveManager:

我以前是不是已经打开过

如果打开过:

opened = true
显示打开状态
不能再领取

所以:

func _ready():
    apply_saved_state()

很自然。

13. 但读档和切地图时有个顺序问题

注意一个细节:

读档时要先恢复 SaveManager.world_state
再加载地图

如果顺序反了:

先加载地图
宝箱 _ready()
宝箱问 SaveManager 有没有打开
 SaveManager 还没恢复 world_state
宝箱以为自己没打开
然后读档才恢复 world_state
宝箱状态就错了

所以读档进入地图的顺序应该是:

1. 读取 save_data
2. SaveManager apply_world_state_data()
3. SceneManager.change_map(saved_map_id)
4. 地图里的宝箱 _ready() / setup 时应用状态
5. 再恢复玩家数据背包任务等

这是这一部分最容易踩的坑之一。

14. 调整读档顺序

上一节可能是:

SceneManager.change_map(map_id, "DefaultSpawn")
SaveManager.apply_save_data(save_data, player, quest_manager)

现在建议拆开:

func load_from_save():
    var save_data = SaveManager.read_save_data()

    if save_data.is_empty():
        return

    SaveManager.apply_world_state_data(save_data.get("world_state", {}))

    var map_id: String = save_data.get("current_map_id", start_map_id)

    SceneManager.change_map(map_id, "DefaultSpawn")

    SaveManager.apply_save_data_after_map_loaded(save_data, player, quest_manager)

也就是说:

world_state 要在地图加载前恢复
player/inventory/equipment/quests 可以在地图加载后恢复

15. SaveManager 分成两步恢复

可以把原来的 apply_save_data() 拆一下:

func apply_save_data_after_map_loaded(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()

读档时:

SaveManager.apply_world_state_data(save_data.get("world_state", {}))
SceneManager.change_map(map_id, "DefaultSpawn")
SaveManager.apply_save_data_after_map_loaded(save_data, player, quest_manager)

16. Boss 已击败状态

Boss 和普通怪不一样。

普通小怪切地图后刷新可以接受。

Boss 一般要保存已击败状态。

Enemy.gd 加:

@export var persistent_id: String = ""
@export var save_defeated_state: bool = false

_ready:

func _ready():
    if save_defeated_state and SaveManager.has_state("defeated_enemies", persistent_id):
        queue_free()
        return

    # 原来的初始化逻辑

死亡奖励时:

func give_death_rewards():
    if reward_given:
        return

    reward_given = true

    if save_defeated_state:
        SaveManager.mark_state("defeated_enemies", persistent_id)

    give_experience_to_attacker()
    drop_loot()

这样 Boss 死亡后,存档记录:

defeated_enemies["forest_boss_slime_king"] = true

下次地图加载时直接不生成它。

17. 普通怪物不要都保存

不要给每只史莱姆都保存死亡状态。

否则存档会越来越大,而且游戏世界会越来越空。

普通怪物建议:

切地图后刷新
读档后刷新
睡觉后刷新
过一天刷新

需要保存的敌人通常是:

Boss
精英怪
剧情怪
一次性遭遇战怪物
守门怪

也就是说:

会影响剧情或奖励重复领取的敌人
才需要保存

18. 机关状态保存

比如村庄里有一个开关,拉下后打开一扇门。

Switch.gd:

extends Area2D

@export var persistent_id: String = ""
@export var prompt_text: String = "按 E 触发"

@export var target_door: NodePath

var activated: bool = false

func _ready():
    apply_saved_state()

func apply_saved_state():
    if SaveManager.has_state("activated_switches", persistent_id):
        activated = true
        apply_effect()

func interact(player: Node):
    if activated:
        print("机关已经触发")
        return

    activated = true
    SaveManager.mark_state("activated_switches", persistent_id)
    apply_effect()

func apply_effect():
    var door = get_node_or_null(target_door)

    if door != null and door.has_method("open"):
        door.open()

Door 可以是:

extends StaticBody2D

var opened: bool = false

func open():
    opened = true
    visible = false
    collision_layer = 0
    collision_mask = 0

第一版这样就够。

19. 机关和门都要保存吗

有两种方式。

方式 A:

只保存机关已触发
门加载时根据机关状态打开

方式 B:

机关保存状态
门也保存状态

第一版推荐方式 A。

因为门是机关效果的结果。

存一个源状态就够:

switch activated = true

地图加载后:

switch.apply_saved_state()

switch.apply_effect()

door.open()

这样不会出现:

机关说没触发
门却说打开了

源数据少,状态就不容易打架。

20. 可采集物状态保存

比如草药采集点:

玩家采集草药
获得 herb x1
草药节点消失
读档后仍然消失

GatherNode.gd:

extends Area2D

@export var persistent_id: String = ""
@export var prompt_text: String = "按 E 采集"

@export var item: ItemData
@export var quantity: int = 1

var collected: bool = false

func _ready():
    apply_saved_state()

func apply_saved_state():
    if SaveManager.has_state("collected_nodes", persistent_id):
        collected = true
        queue_free()

func interact(player: Node):
    if collected:
        return

    collected = true

    if player.has_method("pick_up_item"):
        player.pick_up_item(item, quantity)

    SaveManager.mark_state("collected_nodes", persistent_id)

    queue_free()

这个非常适合一次性采集物。

21. 采集点刷新怎么办

如果你希望草药过一段时间刷新,就不能简单永久保存:

collected_nodes["forest_herb_node_001"] = true

而应该保存采集时间:

"collected_nodes": {
    "forest_herb_node_001": {
        "collected_day": 3
    }
}

然后加载时判断:

当前天数 - collected_day >= 3
则刷新
否则隐藏

不过你现在还没有时间系统。

第一版先做永久采集。

等后面讲时间、日期、季节,再加刷新。

22. 一次性剧情道具

比如地上有一个“古老钥匙”。

玩家捡起后,它永远不该再出现。

这和采集点很像:

PickupOnce

可以用 collected_nodes 保存,也可以单独分类:

collected_unique_items

第一版可以复用:

collected_nodes

因为本质都是:

这个地图物品已经被拿走

23. 使用 group 批量刷新地图状态

地图加载后,有些对象需要应用保存状态。

你可以让这些对象加入 group:

persistent_state

Godot 的 group 可以像标签一样给节点分组,节点可以在编辑器里加入 group,也可以运行时用 Node.add_to_group() 加入;之后可以通过 SceneTree 获取组内节点或调用它们的方法。(Godot Engine documentation)

比如:

Chest 加入 persistent_state
Boss 加入 persistent_state
Switch 加入 persistent_state
GatherNode 加入 persistent_state

然后 MapBase.gd:

func apply_persistent_states():
    for node in get_tree().get_nodes_in_group("persistent_state"):
        if not is_ancestor_of(node):
            continue

        if node.has_method("apply_saved_state"):
            node.apply_saved_state()

不过注意:

get_nodes_in_group 会拿到整个 SceneTree 里这个 group 的节点

所以最好检查:

is_ancestor_of(node)

确保只处理当前地图下面的对象。

24. 更推荐的方式:MapBase 递归查找

也可以不依赖 group,递归查当前地图所有子节点:

func apply_persistent_states():
    apply_persistent_states_recursive(self)

func apply_persistent_states_recursive(node: Node):
    if node.has_method("apply_saved_state"):
        node.apply_saved_state()

    for child in node.get_children():
        apply_persistent_states_recursive(child)

这样只会处理当前地图。

第一版我更推荐这个。

原因:

不用担心 group 里有旧地图节点
不用担心跨地图误处理

MapBase.gd:

func setup_map(context: Dictionary):
    player = context.get("player")
    quest_manager = context.get("quest_manager")
    ui = context.get("ui")

    setup_interactables()
    setup_enemies()
    apply_persistent_states()

25. 但 _ready() 和 setup_map() 会重复吗

如果 Chest 的 _ready() 里已经调用:

apply_saved_state()

MapBase 又调用一次,也可能重复。

大部分情况下没事,因为状态应用应该是幂等的。

“幂等”意思是:

执行一次和执行多次结果一样

比如:

opened = true
update_visual()

执行两次没问题。

queue_free() 这种要小心。

对于 GatherNode:

func apply_saved_state():
    if SaveManager.has_state("collected_nodes", persistent_id):
        collected = true
        queue_free()

执行两次通常也还行,但最好别重复。

你可以统一规定:

持久状态对象在 setup_map() 阶段统一 apply
不要在 _ready()  apply

或者反过来:

每个对象在 _ready() 自己 apply
MapBase 不管

第一版我推荐:

每个对象自己在 _ready()  apply

因为更直观。

等系统多了,再统一到 MapBase。

26. 重要:读档时 world_state 必须先恢复

再强调一次,因为它太容易错。

读档顺序:

读取 save_data

SaveManager.apply_world_state_data()

SceneManager.change_map()

地图对象 _ready()

宝箱/Boss/机关/采集点应用状态

SaveManager.apply_save_data_after_map_loaded()

如果你发现读档后宝箱又能开,优先检查这个顺序。

27. 新游戏时要清空 world_state

开始新游戏时,一定要:

SaveManager.clear_world_state()

否则你从旧存档回来,新游戏里宝箱还是打开的,那就很灵异。

NewGame 流程:

func start_new_game():
    SaveManager.clear_world_state()
    quest_manager.active_quests.clear()
    quest_manager.completed_quests.clear()

    player.reset_to_new_game()
    SceneManager.change_map("forest", "DefaultSpawn")

Player 可以提供:

func reset_to_new_game():
    level = 1
    current_exp = 0
    exp_to_next_level = 100
    gold = 0

    inventory.slots.clear()
    equipped_items[EquipmentData.SlotType.WEAPON] = null
    equipped_items[EquipmentData.SlotType.ARMOR] = null
    equipped_items[EquipmentData.SlotType.ACCESSORY] = null

    base_attack = 10
    base_defense = 0
    base_max_hp = 100

    recalculate_stats()
    current_hp = max_hp

28. 保存商店库存要不要做

第一版不建议。

因为前面商店建议用无限库存。

如果你用了有限库存,并且希望买完后读档仍然没货,那就要保存商店状态。

比如:

"shop_stock": {
    "village_potion_shop": {
        "small_potion": 8,
        "wooden_sword": 0
    }
}

但这会增加复杂度。

第一版先不保存商店库存。

商店每次加载都恢复默认库存即可。

29. 保存掉落物要不要做

第一版也不建议。

比如怪物掉了一个 slime_gel,玩家没捡就切地图。

你可以让它消失。

这很正常。

真正要保存地上掉落物时,需要保存:

item_id
quantity
position
map_id

还要给每个掉落物分配 ID。

暂时没必要。

30. 保存 NPC 位置要不要做

第一版不建议。

NPC 默认站在地图里固定位置即可。

以后如果有:

NPC 日程
NPC 晚上回家
剧情后 NPC 离开村庄

再做 NPC 状态保存。

比如:

npc_states["village_blacksmith"] = {
    "state": "in_shop",
    "position": Vector2(...)
}

现在先不做。

31. 保存门是否解锁

这个可以做。

比如红钥匙门:

玩家用红钥匙打开门
以后这个门永久打开

Door.gd:

@export var persistent_id: String = ""
@export var save_open_state: bool = false

var opened: bool = false

func _ready():
    apply_saved_state()

func apply_saved_state():
    if save_open_state and SaveManager.has_state("activated_switches", persistent_id):
        open()

func open():
    opened = true
    visible = false
    collision_layer = 0
    collision_mask = 0

    if save_open_state:
        SaveManager.mark_state("activated_switches", persistent_id)

这里复用了:

activated_switches

也可以单独做:

opened_doors

更清楚:

"opened_doors": {}

如果你的门很多,建议单独加分类。

32. SaveManager 分类可以继续扩展

现在是:

world_state = {
    "opened_chests": {},
    "defeated_enemies": {},
    "activated_switches": {},
    "collected_nodes": {}
}

以后可以加:

"opened_doors": {},
"removed_obstacles": {},
"changed_npcs": {},
"shop_stock": {}

一开始别加太多。

每加一个分类,都要有明确用途。

33. SaveManager 默认 world_state 函数

为了避免到处复制默认结构,可以写:

func get_default_world_state() -> Dictionary:
    return {
        "opened_chests": {},
        "defeated_enemies": {},
        "activated_switches": {},
        "collected_nodes": {},
        "opened_doors": {}
    }

然后:

var world_state: Dictionary = get_default_world_state()

不过 GDScript 里成员变量初始化时调用函数有时不如在 _ready() 或手动初始化直观。

可以写:

var world_state: Dictionary = {}

func _ready():
    if world_state.is_empty():
        world_state = get_default_world_state()

或者在脚本顶部直接写完整默认字典。

第一版怎么简单怎么来。

34. 地图状态和 current_map_id 的关系

world_state 是整个世界状态。

current_map_id 只是玩家现在在哪张地图。

比如:

current_map_id = village
world_state 里仍然可以有 forest_chest_001 已打开

不要把世界状态只存在当前地图对象里。

否则切地图时旧地图一删,状态也没了。

所以:

长期状态存在 SaveManager / 存档数据里
地图对象只是根据状态表现自己

这个很重要。

35. 一张地图可以重复加载很多次

比如:

进入森林
离开森林
再进入森林

每次 ForestMap 都是新的实例。

所以地图节点里普通变量不会自动保留。

比如 Chest.gd 里:

var opened = true

切地图后,这个 Chest 节点被删。

下次加载森林,新的 Chest:

var opened = false

所以必须靠 SaveManager 里的 world_state 恢复。

这就是为什么地图状态不能只存在节点变量里。

36. 宝箱完整进阶版

extends Area2D

@export var persistent_id: String = ""
@export var prompt_text: String = "按 E 打开"

@export var item: ItemData
@export var quantity: int = 1

@export var closed_texture: Texture2D
@export var opened_texture: Texture2D

var opened: bool = false

@onready var sprite: Sprite2D = $Sprite2D

func _ready():
    apply_saved_state()
    update_visual()

func apply_saved_state():
    if persistent_id == "":
        push_warning("宝箱没有 persistent_id:" + name)
        return

    if SaveManager.has_state("opened_chests", persistent_id):
        opened = true

func interact(player: Node):
    if opened:
        print("宝箱已经打开")
        return

    opened = true

    if player.has_method("pick_up_item"):
        player.pick_up_item(item, quantity)

    SaveManager.mark_state("opened_chests", persistent_id)

    update_visual()

func update_visual():
    if sprite == null:
        return

    if opened:
        if opened_texture != null:
            sprite.texture = opened_texture
        else:
            sprite.modulate = Color(0.6, 0.6, 0.6)
    else:
        if closed_texture != null:
            sprite.texture = closed_texture

        sprite.modulate = Color(1, 1, 1)

37. Boss 完整保存片段

@export var persistent_id: String = ""
@export var save_defeated_state: bool = false
@export var exp_reward: int = 100

var last_attacker: Node = null
var reward_given: bool = false

func _ready():
    if save_defeated_state:
        if persistent_id == "":
            push_warning("保存状态的敌人没有 persistent_id:" + name)
        elif SaveManager.has_state("defeated_enemies", persistent_id):
            queue_free()
            return

    # 原来的初始化逻辑
    current_hp = max_hp
    attack_area.monitoring = false

func give_death_rewards():
    if reward_given:
        return

    reward_given = true

    if save_defeated_state and persistent_id != "":
        SaveManager.mark_state("defeated_enemies", persistent_id)

    give_experience_to_attacker()
    drop_loot()

38. GatherNode 完整版

extends Area2D

@export var persistent_id: String = ""
@export var prompt_text: String = "按 E 采集"

@export var item: ItemData
@export var quantity: int = 1

var collected: bool = false

func _ready():
    apply_saved_state()

func apply_saved_state():
    if persistent_id == "":
        push_warning("采集点没有 persistent_id:" + name)
        return

    if SaveManager.has_state("collected_nodes", persistent_id):
        collected = true
        queue_free()

func interact(player: Node):
    if collected:
        return

    collected = true

    if player.has_method("pick_up_item"):
        player.pick_up_item(item, quantity)

    SaveManager.mark_state("collected_nodes", persistent_id)

    queue_free()

39. Switch 完整版

extends Area2D

@export var persistent_id: String = ""
@export var prompt_text: String = "按 E 触发"

@export var target_node: NodePath
@export var target_method: String = "open"

var activated: bool = false

func _ready():
    apply_saved_state()

func apply_saved_state():
    if persistent_id == "":
        push_warning("机关没有 persistent_id:" + name)
        return

    if SaveManager.has_state("activated_switches", persistent_id):
        activated = true
        apply_effect()

func interact(player: Node):
    if activated:
        print("机关已经触发")
        return

    activated = true
    SaveManager.mark_state("activated_switches", persistent_id)
    apply_effect()

func apply_effect():
    var target = get_node_or_null(target_node)

    if target == null:
        return

    if target.has_method(target_method):
        target.call(target_method)

这个版本比较通用。

你可以让机关调用目标节点的:

open
unlock
remove
activate

40. 常见错误 1:宝箱读档后又能打开

排查:

Chest.persistent_id 是否填写
SaveManager.mark_state 是否调用
save_data 里是否保存 world_state
读档时是否 apply_world_state_data
apply_world_state_data 是否发生在 change_map 之前
Chest._ready 是否调用 apply_saved_state

最常见的就是:

读档顺序错了

记住:

world_state 先恢复
地图后加载

41. 常见错误 2:所有宝箱一起打开

原因通常是:

多个宝箱用了同一个 persistent_id

比如都叫:

chest

那打开一个,其他所有 ID 为 chest 的宝箱都会认为自己打开了。

解决:

每个宝箱 ID 必须唯一

比如:

forest_chest_001
forest_chest_002
village_chest_001

42. 常见错误 3:Boss 打死后读档又出现

排查:

Boss.save_defeated_state 是否为 true
Boss.persistent_id 是否填写
give_death_rewards 是否调用 SaveManager.mark_state
world_state 是否保存到文件
读档时 world_state 是否先恢复
Boss._ready 是否检查 defeated_enemies

43. 常见错误 4:采集点切地图回来又刷新

如果你希望永久不刷新,检查:

GatherNode.persistent_id 是否填写
采集后是否 SaveManager.mark_state("collected_nodes", persistent_id)
重新加载地图时是否 apply_saved_state

如果你希望它过几天刷新,那就不是 bug,而是需要时间系统。

当前第一版是永久采集。

44. 常见错误 5:新游戏里宝箱还是打开

原因:

新游戏时没有清空 SaveManager.world_state

开始新游戏时必须:

SaveManager.clear_world_state()

还要清空:

背包
装备
金币
等级
任务

否则新游戏会继承旧进度。

45. 常见错误 6:机关触发了,但门读档后没开

排查:

Switch.persistent_id 是否保存
Switch._ready 是否 apply_saved_state
Switch.apply_saved_state 是否调用 apply_effect
target_node 路径是否正确
target_method 是否存在
门的 open() 是否真的关闭碰撞

机关状态保存了,不代表门会自动变。

必须在加载时重新执行机关效果:

apply_effect()

46. 常见错误 7:调用 target.call(target_method) 报错

检查:

target_node 是否拖了
target_method 名字是否拼对
目标节点是否有这个方法

比如目标方法是:

func open():

target_method 就要是:

open

不要写:

Open
open()

47. 当前推荐 SaveManager 结构

现在 SaveManager 至少有:

基础存档
player
inventory
equipment
quests
current_map_id

世界状态
world_state
  opened_chests
  defeated_enemies
  activated_switches
  collected_nodes
  opened_doors

整体存档:

{
    "version": 1,
    "current_map_id": SceneManager.current_map_id,
    "player": {...},
    "inventory": [...],
    "equipment": {...},
    "quests": {...},
    "world_state": {
        "opened_chests": {...},
        "defeated_enemies": {...},
        "activated_switches": {...},
        "collected_nodes": {...},
        "opened_doors": {...}
    }
}

这个结构已经能支撑一个很完整的 RPG 原型了。

48. 这一部分最重要的记忆点

地图节点会被反复创建和销毁
所以地图状态不能只存在节点变量里
需要长期保存的地图对象必须有 persistent_id
宝箱Boss机关采集点都适合保存状态
普通小怪和普通掉落物第一版不建议保存
world_state 应该进入 SaveManager 和存档文件
读档时必须先恢复 world_state再加载地图
新游戏时必须清空 world_state
每个 persistent_id 必须唯一且稳定

49. 你现在的小练习

按这个顺序做:

1. SaveManager 添加 world_state
2. SaveManager 添加 mark_state()
3. SaveManager 添加 has_state()
4. SaveManager 添加 clear_world_state()
5. SaveManager 保存 world_state save_data
6. SaveManager 读档时恢复 world_state
7. 修改读档流程 apply_world_state_data change_map
8. Chest 添加 persistent_id
9. Chest 打开后 mark_state("opened_chests", persistent_id)
10. Chest._ready 里根据 opened_chests 恢复打开状态
11. forest 放两个宝箱ID 分别是 forest_chest_001 / forest_chest_002
12. 打开其中一个宝箱
13. F5 保存
14. 重启F9 读档
15. 确认这个宝箱仍然打开另一个没有打开
16. Boss 添加 persistent_id save_defeated_state
17. Boss 死亡后保存 defeated_enemies
18. 读档后 Boss 不再出现
19. 添加 GatherNode
20. 采集后保存 collected_nodes
21. 读档后采集点不再出现
22. 添加 Switch
23. Switch 触发后保存 activated_switches
24. 读档后机关效果仍然生效
25. 测试新游戏时 clear_world_state所有宝箱恢复关闭

完成这一部分之后,你的 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 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...