第二十部分:地图状态保存:宝箱已打开、Boss 已击败、机关已触发、采集点刷新
上一部分我们已经能做到:
切换地图
室内外传送
读档进入指定地图
保存玩家位置
保存当前地图 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 就不只是“能存玩家数据”,而是开始真正保存“世界发生过什么”。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录