让我们通过制作 RPG 来学习 Godot 4 — 第 20 部分:保存和加载系统
在这一部分,我们将为玩家添加保存和加载游戏的功能。当我们保存游戏时,需要存储所有不同脚本中的必要变量,以保存这些变量的当前状态。
你将学到的内容:
- 如何创建持久的保存和加载系统
- 如何解析JSON文件
- 如何使用FileAccess对象读写文件
- 如何保存/加载游戏变量
我们将这些变量保存在一个字典中,字典将存储我们的值作为键。字典的语法与JSON类似,这对我们很有帮助,因为我们将把保存文件保存为JSON格式。JSON是一种开放标准的文件格式和数据交换格式,它使用人类可读的文本来存储和传输由属性-值对和数组组成的数据对象。这对于将数据序列化以保存到文件或通过网络发送非常有用。
我们的保存文件将遵循以下格式:
save_dictionary = {
"variable name": variable reference,
"player_health": health
}
保存系统概述
为了从我们创建的保存文件中加载游戏,我们需要将JSON文件转换回字典格式。我们将通过一个函数来实现这一点,该函数将从JSON文件中加载数据。
我们的加载函数将遵循以下格式:
func load_save_file(data):
variable name = data.variable reference
player_health = data.health
加载系统概述
让我们从保存功能开始吧!
保存游戏
在我们的全局脚本中,创建一个新变量来保存JSON保存文件的路径。我们将路径设置为"user://dusty_trails_save.json"
。
在Windows机器上,这个保存文件将位于%APPDATA%\Roaming\Godot\app_userdata\Dusty-Trails
。
### Global.gd
# 旧代码
# 保存与加载
var save_path = "user://dusty_trails_save.json"
为了保存游戏,我们需要在每个脚本中创建一个字典来存储我们的值。然后,我们将所有这些字典存储在主场景中的另一个字典中,该字典将所有单独的字典编译成一个单一的结构,然后将其存储在我们的JSON文件中。
我们想要从以下脚本中保存以下值:
- Player -> 位置、生命值、拾取物、耐力、经验值和等级
- Enemy -> 位置、生命值
- EnemySpawner -> 生成的敌人
- NPC -> 位置、任务状态、任务完成状态
在每个脚本的末尾,让我们创建这些字典来存储这些值。我们将敌人存储在EnemySpawner中作为一个数组,这样当我们加载敌人时,生成器知道从多少开始继续计数以添加/删除敌人数量。我们还将从Enemy脚本的data_to_save()
函数中附加敌人数据到EnemySpawner的保存函数中。因此,我们的生成器保存了敌人的数量以及每个敌人的生命值和位置值。
### Player.gd
# 旧代码
# -------------------------------- 保存与加载 -----------------------
# 要保存的数据
func data_to_save():
return {
"position" : [position.x, position.y],
"health" : health,
"max_health" : max_health,
"stamina" : stamina,
"max_stamina" : max_stamina,
"xp" : xp,
"xp_requirements" : xp_requirements,
"level" : level,
"ammo_pickup" : ammo_pickup,
"health_pickup" : health_pickup,
"stamina_pickup" : stamina_pickup
}
### NPC.gd
# 旧代码
# -------------------------------- 保存与加载 -----------------------
# 要保存的数据
func data_to_save():
return {
"position" : [position.x, position.y],
"quest_status": quest_status,
"quest_complete": quest_complete
}
### Enemy.gd
# 旧代码
# -------------------------------- 保存与加载 -----------------------
# 要保存的数据
func data_to_save():
return {
"position" : [position.x, position.y],
"health" : health,
"max_health" : max_health
}
### EnemySpawner.gd
# 旧代码
# -------------------------------- 保存与加载 -----------------------
# 要保存的数据
func data_to_save():
var enemies = []
for enemy in spawned_enemies.get_children():
# 保存敌人数量及其存储的生命值和位置值
if enemy.name.find("Enemy") >= 0:
enemies.append(enemy.data_to_save())
return enemies
现在,在我们的全局脚本中,我们需要创建一个新函数来保存游戏。在这个函数中,我们需要创建一个要保存的项目的字典,其中包括我们在Player、EnemySpawner和NPC脚本中添加的字典。我们还仅在当前场景中存在节点时才保存数据。这意味着只有在场景中有NPC时才会保存NPC数据。如果没有,它将跳过该NPC的数据并保存其他有效的数据字段。总之,此函数通过检索当前场景、从场景中的特定节点收集数据、将数据转换为JSON格式并将其存储在文件中来保存游戏。保存的数据包括场景名称、玩家数据、NPC数据和敌人生成器数据(如果它们存在于当前场景中)。
为了将文件保存到此路径,我们必须使用FileAccess对象。我们首先需要将“data”字典转换为JSON字符串。我们可以使用stringify
方法来实现这一点,该方法将数据转换为JSON格式的字符串。
然后,我们将使用FileAccess对象的.open
方法打开我们的save_path
文件。打开此文件允许我们读取或写入它。在这种情况下,我们将使用FileAccess.WRITE
方法指定文件应以写入模式打开。
打开后,我们将使用store_line
函数写入此文件,该函数将字符串写入文件并在末尾添加换行符。
完成所有这些操作后,我们需要关闭文件。这对于确保所有数据都已写入并释放资源非常重要。
# ------------------------ 保存与加载 --------------------------
# 保存游戏
func save():
var current_scene = get_tree().get_current_scene()
if current_scene != null:
current_scene_name = current_scene.name
# 要保存的数据
var data = {
"scene_name" : current_scene_name,
}
# 在保存前检查节点是否存在
if current_scene.has_node("Player"):
var player = get_tree().get_root().get_node("%s/Player" % current_scene_name)
print("Player exists: ", player != null)
data["player"] = player.data_to_save()
if current_scene.has_node("SpawnedNPC/NPC"):
var npc = get_tree().get_root().get_node("%s/SpawnedNPC/NPC" % current_scene_name)
print("NPC exists: ", npc != null)
data["npc"] = npc.data_to_save()
if current_scene.has_node("EnemySpawner"):
var enemy_spawner = get_tree().get_root().get_node("%s/EnemySpawner" % current_scene_name)
print("EnemySpawner exists: ", enemy_spawner != null)
data["enemies"] = enemy_spawner.data_to_save()
# 将字典(data)转换为json
var json = JSON.new()
var to_json = json.stringify(data)
# 打开保存文件进行写入
var file = FileAccess.open(save_path, FileAccess.WRITE)
# 写入保存文件
file.store_line(to_json)
# 关闭文件
file.close()
else:
print("No active scene. Cannot save.")
现在,我们将在切换场景时保存游戏。这将防止我们的游戏返回场景路径的<null>
值。它还帮助我们将上一个场景中最后捕获的玩家数据带入新场景。
### Global.gd
# 旧代码
# 切换场景
func change_scene(scene_path):
save()
# 获取当前场景
current_scene_name = scene_path.get_file().get_basename()
var current_scene = get_tree().get_root().get_child(get_tree().get_root().get_child_count() - 1)
# 释放它以加载新场景
current_scene.queue_free()
# 切换场景
var new_scene = load(scene_path).instantiate()
get_tree().get_root().call_deferred("add_child", new_scene)
get_tree().call_deferred("set_current_scene", new_scene)
call_deferred("post_scene_change_initialization")
func post_scene_change_initialization():
scene_changed.emit()
现在我们需要回到我们的Player脚本,并在按下保存按钮时调用全局脚本中的保存函数。
### Player.gd
# 旧代码
# 保存游戏
func _on_save_pressed():
Global.save()
如果你现在玩游戏并保存,你的保存文件应该会被创建。如果你打开它,保存文件将如下所示(你的值将与我的不同):
JSON SAVE FILE DATA:
{"enemies":[],"player":{"ammo_pickup":13,"health":100,"health_pickup":2,"level":1,"max_health":100,"max_stamina":100,"position":[439,154],"stamina":100,"stamina_pickup":2,"xp":0,"xp_requirements":100},"scene_name":"Main"}
加载游戏
为了加载游戏,我们将再次访问每个脚本,并定义我们想要从中加载的值。
我们想要从以下脚本中加载以下值:
- Player -> 位置、生命值、拾取物、耐力、经验值和等级
- Enemy -> 位置、生命值
- EnemySpawner -> 生成的敌人
- NPC -> 位置、任务状态、任务完成状态
我们将在每个Player、NPC、Enemy和EnemySpawner脚本中创建一个函数来实现这一点。此函数将传递“data”参数,该参数将引用我们在主场景中创建的“data”字典中的保存值。
### Player.gd
# 旧代码
# 从保存的数据中加载数据
func data_to_load(data):
position = Vector2(data.position[0], data.position[1])
health = data.health
max_health = data.max_health
stamina = data.stamina
max_stamina = data.max_stamina
xp = data.xp
xp_requirements = data.xp_requirements
level = data.level
ammo_pickup = data.ammo_pickup
health_pickup = data.health_pickup
stamina_pickup = data.stamina_pickup
### EnemySpawner.gd
# 旧代码
# 从保存文件中加载数据
func data_to_load(data):
enemy_count = data.size()
for enemy_data in data:
var enemy = Global.enemy_scene.instantiate()
enemy.data_to_load(enemy_data)
add_child(enemy)
### NPC.gd
# 旧代码
# 从保存中加载数据
func data_to_load(data):
position = Vector2(data.position[0], data.position[1])
quest_status = int(data.quest_status)
quest_complete = data.quest_complete
### Enemy.gd
# 旧代码
# 从保存文件中加载数据
func data_to_load(data):
position = Vector2(data.position[0], data.position[1])
health = data.health
max_health = data.max_health
现在,我们想要加载整个游戏数据。在我们的加载函数中,我们将通过读取JSON格式的保存文件来加载保存的游戏状态,加载相应的场景,将其添加到场景树中,将其设置为当前场景,并将保存的数据加载到场景中的特定节点中。我们仅在保存文件中存在数据时加载相应节点(如npc、player和enemy)的数据。
我们可以通过file_exists
方法来实现这一点。如果文件存在,我们需要打开文件并读取其内容。我们可以通过指定使用FileAccess.READ
方法来读取文件。打开后,我们需要使用file.get_as_text()
将文件的全部内容读取为文本,并使用JSON.parse_string()
将其解析为JSON。
然后我们需要关闭文件。读取文件后,我们需要从解析的JSON中加载玩家、敌人生成器和npc的数据。如果任务状态设置为完成,我们还需要从场景中移除任务物品。你也可以对拾取物执行相同的操作,但我希望我们的拾取物在加载时重新生成。
### Global.gd
# 旧代码
# 保存与加载
var save_path = "user://dusty_trails_save.json"
var loading = false
# 旧代码
func load_game():
if loading and FileAccess.file_exists(save_path):
print("Save file found!")
var file = FileAccess.open(save_path, FileAccess.READ)
var data = JSON.parse_string(file.get_as_text())
file.close()
# 加载保存的场景
var scene_path = "res://Scenes/%s.tscn" % data["scene_name"]
print(scene_path)
var game_resource = load(scene_path)
var game = game_resource.instantiate()
# 切换到加载的场景
get_tree().root.call_deferred("add_child", game)
get_tree().call_deferred("set_current_scene", game)
current_scene_name = game.name
# 现在你可以将数据加载到节点中
var player = game.get_node("Player")
var npc = game.get_node("SpawnedNPC/NPC")
var enemy_spawner = game.get_node("EnemySpawner")
# 在加载数据之前检查它们是否有效
if player:
player.data_to_load(data["player"])
if npc:
npc.data_to_load(data["npc"])
if enemy_spawner:
enemy_spawner.data_to_load(data["enemies"])
if(npc and npc.quest_complete):
game.get_node("SpawnedQuestItems/QuestItem").queue_free()
else:
print("Save file not found!")
我们还需要创建一个函数,当玩家进入新场景时加载玩家的数据。此函数将通过检查是否存在保存文件、读取并解析文件以获取玩家数据,并将数据加载到当前场景的“Player”节点中(如果存在),从而在进入新场景时加载玩家的数据(如生命值、弹药和金币数量)。这将允许我们的UI组件在游戏加载时显示正确的值。
## Global.gd
# 切换场景时加载玩家数据
func load_data():
var current_scene = get_tree().get_current_scene()
if current_scene and FileAccess.file_exists(save_path):
print("Save file found!")
var file = FileAccess.open(save_path, FileAccess.READ)
var data = JSON.parse_string(file.get_as_text())
file.close()
# 现在你可以将数据加载到节点中
var player = current_scene.get_node("Player")
if player and data.has("player"):
player.values_to_load(data["player"])
else:
print("Save file not found!")
当此函数加载玩家数据时,它会在Player脚本中查找一个名为values_to_load()
的函数。这是一个新函数,它加载所有玩家的数据,除了位置,因为我们不希望位置加载到地图上的某个随机区域(这可能是超出边界的区域)!在你的Player脚本中,让我们创建这个函数。
## Player.gd
# 旧代码
func values_to_load(data):
health = data.health
max_health = data.max_health
stamina = data.stamina
max_stamina = data.max_stamina
xp = data.xp
xp_requirements = data.xp_requirements
level = data.level
ammo_pickup = data.ammo_pickup
health_pickup = data.health_pickup
stamina_pickup = data.stamina_pickup
coins = data.coins
# 发出信号以更新UI
health_updated.emit(health, max_health)
stamina_updated.emit(stamina, max_stamina)
ammo_pickups_updated.emit(ammo_pickup)
health_pickups_updated.emit(health_pickup)
stamina_pickups_updated.emit(stamina_pickup)
xp_updated.emit(xp)
level_updated.emit(level)
coins_updated.emit(coins)
# 直接更新UI组件
$UI/AmmoAmount/Value.text = str(data.ammo_pickup)
$UI/StaminaAmount/Value.text = str(data.stamina_pickup)
$UI/HealthAmount/Value.text = str(data.health_pickup)
$UI/XP/Value.text = str(data.xp)
$UI/XP/Value2.text = "/ " + str(data.xp_requirements)
$UI/Level/Value.text = str(data.level)
$UI/CoinAmount/Value.text = str(data.coins)
我们现在还需要更新MainMenu脚本的代码以调用我们新创建的函数。我们的加载按钮将调用全局脚本中的load_game()
函数。
### MainScene.gd
extends Node2D
func _ready():
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
# 新游戏
func _on_new_pressed():
Global.change_scene("res://Scenes/Main.tscn")
Global.scene_changed.connect(_on_scene_changed)
# 加载游戏
func _on_load_pressed():
Global.loading = true
Global.load_game()
queue_free()
# 退出游戏
func _on_quit_pressed():
get_tree().quit()
# 只有在场景切换后,我们才释放资源
func _on_scene_changed():
queue_free()
最后,我们还需要在切换场景时加载数据,以保持数据的持久性。
### Global.gd
# 旧代码
# ----------------------- 场景处理 ----------------------------
# 加载时设置当前场景
func _ready():
current_scene_name = get_tree().get_current_scene().name
# 切换场景
func change_scene(scene_path):
我们还需要在玩家的_ready
函数中设置玩家的UI值,以便在它们进入新场景时更新这些值。
# 之前的代码
func _ready():
# 将信号连接到UI组件的函数
health_updated.connect(health_bar.update_health_ui)
stamina_updated.connect(stamina_bar.update_stamina_ui)
ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui)
health_pickups_updated.connect(health_amount.update_health_pickup_ui)
stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui)
xp_updated.connect(xp_amount.update_xp_ui)
xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui)
level_updated.connect(level_amount.update_level_ui)
coins_updated.connect(coin_amount.update_coin_amount_ui)
# 重置颜色
animation_sprite.modulate = Color(1,1,1,1)
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
# 更新UI组件以显示正确加载的数据
$UI/AmmoAmount/Value.text = str(ammo_pickup)
$UI/StaminaAmount/Value.text = str(stamina_pickup)
$UI/HealthAmount/Value.text = str(health_pickup)
$UI/XP/Value.text = str(xp)
$UI/XP/Value2.text = "/ " + str(xp_requirements)
$UI/Level/Value.text = str(level)
现在,如果您运行场景,应该能够像往常一样保存/加载。您还应该能够在场景之间切换,并且之前场景的数据应该能够继承!
喜,现在您已经拥有了一个持久的保存和加载系统!记得保存您的项目,我们下部分课程再见