在这一部分,我们将为玩家添加保存和加载游戏的功能。当我们保存游戏时,需要存储所有不同脚本中的必要变量,以保存这些变量的当前状态。

你将学到的内容:

  • 如何创建持久的保存和加载系统
  • 如何解析JSON文件
  • 如何使用FileAccess对象读写文件
  • 如何保存/加载游戏变量

我们将这些变量保存在一个字典中,字典将存储我们的值作为键。字典的语法与JSON类似,这对我们很有帮助,因为我们将把保存文件保存为JSON格式。JSON是一种开放标准的文件格式和数据交换格式,它使用人类可读的文本来存储和传输由属性-值对和数组组成的数据对象。这对于将数据序列化以保存到文件或通过网络发送非常有用。

我们的保存文件将遵循以下格式:

save_dictionary = {
  "variable name": variable reference,
  "player_health": health
}

1.jpg
保存系统概述

为了从我们创建的保存文件中加载游戏,我们需要将JSON文件转换回字典格式。我们将通过一个函数来实现这一点,该函数将从JSON文件中加载数据。

我们的加载函数将遵循以下格式:

func load_save_file(data):
  variable name = data.variable reference
  player_health = data.health

2.jpg
加载系统概述

让我们从保存功能开始吧!

保存游戏

在我们的全局脚本中,创建一个新变量来保存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()

如果你现在玩游戏并保存,你的保存文件应该会被创建。如果你打开它,保存文件将如下所示(你的值将与我的不同):
3.jpg

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)

现在,如果您运行场景,应该能够像往常一样保存/加载。您还应该能够在场景之间切换,并且之前场景的数据应该能够继承!
4.jpg
5.jpg
喜,现在您已经拥有了一个持久的保存和加载系统!记得保存您的项目,我们下部分课程再见

标签: Godot, 2D游戏, 游戏开发, 游戏自学

添加新评论