Tutorial de Godot – Parte 19: Menú de inicio – Cambio de escena – Guardar y cargar el juego

En este tutorial, haremos lo siguiente:

  • cree el menú de inicio: aquí puede encontrar los elementos del menú para comenzar un nuevo juego, cargar el guardado y salir del juego;
  • desarrollar la funcionalidad de guardar y cargar (para simplificar las cosas, solo tendremos un espacio para guardar);
  • edite el menú del juego y la pantalla de finalización del juego para volver a la pantalla de inicio.

Creando la escena del menú de inicio

La siguiente imagen muestra el resultado final que obtendremos. Es muy similar al menú del juego que creamos en la Parte 16: Menú de pausa. Reiniciar y salir del juego :

Gráficamente, es un menú muy básico: siéntase libre de agregar el logotipo de su juego y los gráficos que desee.

Para comenzar, creemos una nueva escena para el menú de inicio haciendo clic en Escena → Nueva escena . Luego, en el panel Escena , haga clic en el botón Escena 2D para crear un nodo raíz Nodo2D . Cámbiele el nombre Pantalla de inicio.

Agregue un nodo ColorRect a StartScreen y cambie su tamaño a 320 × 180 píxeles: será nuestro fondo de menú. Cambie su propiedad Color a negro (0,0,0,255).

Ahora, agregue otro ColorRect a StartScreen y cámbiele el nombre NewGame . Establezca Rect → Posición en (70, 50) y Rect → Tamaño en (180, 20). Ahora agregue una etiqueta a NewGame. En la propiedad Fuentes personalizadas , cargue Font.tres (lo encontrará en la carpeta Fuentes ) y en Colores personalizados , configure el Color de fuente en negro. Escriba NUEVO JUEGO en la propiedad Texto de la etiqueta, luego establezca Alinear y Valign en Centro . Finalmente, configure el Rect → Tamaño de la etiqueta a (180, 20).

Ahora, duplica NewGame dos veces. Cambie el nombre de una copia a LoadGame y la otra a Quit .

Establece Rect → Position de LoadGame en (70, 80), luego cambia el texto de su etiqueta a LOAD GAME. Seleccione el nodo Salir y establezca su Rect → Posición en (70, 110). Finalmente, cambie el texto de su etiqueta a SALIR.

Finalmente, agreguemos algo de música de fondo a nuestro menú. Agregue un nodo AudioStreamPlayer a StartScreen y en la propiedad Stream cargue el archivo night-chip.ogg (puede encontrarlo en la carpeta Sonidos ). Habilite la propiedad de reproducción automática para reproducir música cuando comience el juego.

Terminará con esta estructura de nodos para la escena StartScreen :

Guarde la escena en la carpeta Escenas y llámela StartScreen.tscn.

Guión del menú de inicio

Adjunte un nuevo script al nodo StartScreen , llámelo StartScreen.gd y guárdelo en la carpeta GUI . Copie este código dentro del script:

extends Node2D

var selected_menu = 0

func change_menu_color():
	$NewGame.color = Color.gray
	$LoadGame.color = Color.gray
	$Quit.color = Color.gray
	
	match selected_menu:
		0:
			$NewGame.color = Color.greenyellow
		1:
			$LoadGame.color = Color.greenyellow
		2:
			$Quit.color = Color.greenyellow

func _ready():
	change_menu_color()

func _input(event):
	if Input.is_action_just_pressed("ui_down"):
		selected_menu = (selected_menu + 1) % 3;
		change_menu_color()
	elif Input.is_action_just_pressed("ui_up"):
		if selected_menu > 0:
			selected_menu = selected_menu - 1
		else:
			selected_menu = 2
		change_menu_color()
	elif Input.is_action_just_pressed("attack"):
		match selected_menu:
			0:
				# New game
				get_tree().change_scene("res://Scenes/Main.tscn")
			1:
				# Load game
				var next_level_resource = load("res://Scenes/Main.tscn");
				var next_level = next_level_resource.instance()
				next_level.load_saved_game = true
				get_tree().root.call_deferred("add_child", next_level)
				queue_free()
			2:
				# Quit game
				get_tree().quit()

El funcionamiento interno del menú es el mismo que el menú del juego que construimos en la Parte 16 . Echemos un vistazo más de cerca a lo que sucede cuando seleccionamos uno de los elementos del menú.

Cuando seleccionamos NUEVO JUEGO, el script llama a la función change_scene() del SceneTree actual para cargar la escena principal del juego.

En cambio, cuando seleccionamos CARGAR JUEGO, usamos un método diferente para cargar la escena principal:

  • Primero, usamos la instrucción load() para cargar la escena en la memoria como un recurso PackedScene .
  • La escena ahora está en la memoria, pero aún no es un nodo. Para crear el nodo real, debemos llamar a la función instance() de PackedScene .
  • Ahora que la escena es un nodo real, establecemos la variable load_saved_game en verdadero. Esta variable (que declararemos en breve) será utilizada por el nodo raíz de la escena principal para decidir si carga o no los datos de la partida guardada.
  • En este punto, agregamos el nodo recién creado a la escena actual. Debemos llamar a add_child() a través de call_deferred() porque tenemos que esperar a que el nodo esté completamente inicializado (es decir, todos los nodos secundarios cargados).
  • Finalmente, le decimos a Godot que elimine el menú de inicio del árbol de nodos llamando a la función queue_free() .

El último elemento del menú es SALIR. Para salir del juego, simplemente llamamos a la función quit() del SceneTree actual .

Como se vio arriba, el script que acabamos de crear se refiere a la variable load_saved_game que aún no existe. Para declararlo, vaya a la escena Main.tscn y adjunte un script al nodo raíz . Llámelo SaveLoadGame.gd y colóquelo en una nueva carpeta llamada Scripts . Por ahora, simplemente ingresa este código para declarar la variable load_saved_game y agregar una nueva función llamada save (la implementaremos más adelante cuando guardemos el juego):

var load_saved_game = false

func save():
	pass

Ahora haga clic en el menú Proyecto → Configuración del proyecto y vaya a la sección Aplicación → Ejecutar . Cambie la configuración de la escena principal para ejecutar la escena StartScreen.tscn al comienzo del juego. Ejecuta el juego para probar el menú.

Menú del juego

Antes de ver cómo guardar el juego, debemos actualizar el menú del juego para agregar los elementos del menú Guardar y Volver al inicio . En la escena principal, vaya al nodo MenuPopup . Cambie el nombre del nodo Reiniciar a Guardar juego y el nodo Salir a MainMenu , y luego cambie el texto de sus etiquetas a GUARDAR JUEGO y MENÚ PRINCIPAL. Luego, abra el script MenuPopup.gd y en la función change_menu_color() reemplace cualquier referencia a $Restart con $SaveGame , y las de $Quit con $MainMenu :

func change_menu_color():
	$Resume.color = Color.gray
	$SaveGame.color = Color.gray
	$MainMenu.color = Color.gray
	
	match selected_menu:
		0:
			$Resume.color = Color.greenyellow
		1:
			$SaveGame.color = Color.greenyellow
		2:
			$MainMenu.color = Color.greenyellow

Ahora, en la función _input() , necesitamos reemplazar el código de los elementos del menú actualizados (índice 1 y 2). El nuevo código es el siguiente:

...

1:
	# Save Game
	get_node("/root/Root").save()
	get_tree().paused = false
	hide()
2:
	# Back to start screen
	get_node("/root/Root").queue_free()
	get_tree().change_scene("res://Scenes/StartScreen.tscn")
	get_tree().paused = false

El código para el elemento del menú Guardar juego llama a la función guardar () del nodo raíz que cancela la pausa y oculta el menú del juego. Para volver al menú principal, liberamos la escena actual, cargamos la escena de la pantalla de inicio y cancelamos la pausa.

Guardar datos de archivo

Guardar un juego es la acción de crear o actualizar un archivo que contiene todo el progreso realizado por los jugadores. Esto les permite interrumpir el juego y reanudarlo más tarde, o incluso volver a intentar el juego varias veces en caso de derrota, reiniciando desde un momento anterior en el tiempo.

Para guardar el juego, necesitamos:

  1. identificar toda la información necesaria para reconstruir el estado actual del juego;
  2. elegir el método más eficaz para almacenar esta información.

Comencemos desde el punto 1. Las entidades que cambian durante el juego, y por lo tanto deben almacenarse, son las siguientes:

  • El jugador : para reconstruir su estado, debemos guardar su posición, su salud, su maná, los puntos de experiencia, el nivel y su inventario.
  • Fiona : para almacenar el estado de Fiona, es suficiente saber el estado de su búsqueda y si su collar ha sido encontrado o no.
  • Los esqueletos : tenemos que almacenar una lista de esqueletos, guardando la posición y salud de cada uno.
  • Objetos seleccionables : al comienzo del juego, en el mapa hay dos pociones que el jugador puede recoger; tenemos que almacenar si ya se han tomado o no.

En cuanto al punto 2, decidí guardar el estado del juego en formato JSON . JSON (Notación de objetos de JavaScript) es un formato de datos que utiliza texto legible por humanos para almacenar datos en pares de atributo-valor.

Una ventaja significativa de usar JSON para guardar datos es el notable parecido con los diccionarios de Godot. Un diccionario es un tipo de datos que contiene valores a los que se hace referencia mediante claves únicas (por lo que se compone de pares de claves y valores como JSON). Puede definir un diccionario colocando una lista separada por comas de claves: pares de valores entre llaves {}, como veremos en breve.

En Godot, se pueden usar algunas funciones muy convenientes para convertir diccionarios directamente en cadenas JSON y viceversa. Guardar el juego requiere crear un diccionario que contenga información de todas las entidades del juego y luego convertirlo a formato JSON y guardarlo como un archivo .

La forma más conveniente de construir este diccionario es que cada objeto pueda crear una «traducción» de sí mismo como un diccionario. Este diccionario, a su vez, se puede utilizar como un valor dentro de otro diccionario, creando así una estructura de árbol que se puede convertir en un solo archivo JSON. Por lo tanto, proporcionaremos a cada nodo que deba guardarse una función to_dictionary() que manejará esta traducción.

A continuación se enumeran todos los scripts que se modificarán y sus respectivas funciones que se agregarán:

jugador.gd

func to_dictionary():
	return {
		"position" : [position.x, position.y],
		"health" : health,
		"health_max" : health_max,
		"mana" : mana,
		"mana_max" : mana_max,
		"xp" : xp,
		"xp_next_level" : xp_next_level,
		"level" : level,
		"health_potions" : health_potions,
		"mana_potions" : mana_potions
	}

La posición variable es un Vector2 , que es un tipo de Godot que no se puede convertir directamente a JSON. Entonces, lo almacenaremos como una matriz que contiene 2 elementos (las coordenadas x e y).

Fiona.gd

func to_dictionary():
	return {
		"quest_status" : quest_status,
		"necklace_found" : necklace_found
	}

Esqueleto.gd

func to_dictionary():
	return {
		"position" : [position.x, position.y],
		"health" : health
	}

EsqueletoSpawner.gd

func to_dictionary():
	var skeletons = []
	for node in get_children():
		if node.name.find("Skeleton") >= 0:
			skeletons.append(node.to_dictionary())
	return skeletons

Esta función devuelve una matriz de diccionarios, cada uno de los cuales contiene los datos de un esqueleto.

El último dato que queremos guardar es si las dos pociones presentes al comienzo del juego ya se han tomado o no. En este caso, no necesitamos crear la función to_dictionary() , sino que simplemente comprobaremos que las pociones están presentes o no en el árbol de escenas. Pero para distinguir estas pociones de las instancias posteriores, primero debemos cambiarles el nombre respectivamente a StartPotion1 y StartPotion2 .

En este punto, estamos listos para escribir el código que realmente salva el juego.

guardando el juego

Vayamos al script SaveLoadGame.gd y editemos la función save() de esta manera:

func save():
	var data = {
		"player" : $Player.to_dictionary(),
		"fiona" : $Fiona.to_dictionary(),
		"skeletons" : $SkeletonSpawner.to_dictionary(),
		"potion1" : is_instance_valid(get_node("/root/Root/StartPotion1")),
		"potion2" : is_instance_valid(get_node("/root/Root/StartPotion2"))
	}
	
	var file = File.new()
	file.open("user://savegame.json", File.WRITE)
	var json = to_json(data)
	file.store_line(json)
	file.close()

Primero, la función crea un diccionario llamado data , que contiene algunas propiedades cuyos valores son los diccionarios generados por las funciones to_dictionary() que vimos anteriormente.

Para las dos pociones, usamos la función is_instance_valid() para averiguar si todavía son nodos válidos (y, por lo tanto, aún están presentes en el juego).

Para guardar el archivo, primero creamos un nuevo objeto de tipo Archivo . Luego llamamos al método open() , que requiere dos parámetros: la ruta al archivo a abrir y el modo de apertura (en este caso, queremos escribir en el archivo).

La ruta del archivo utiliza la ruta especial user:// , que representa, independientemente de la plataforma, la ubicación disponible para que el usuario guarde los archivos. La ubicación real de esta carpeta depende del sistema operativo; por ejemplo, en Windows, es %APPDATA%\Godot\app_userdata\Project-Name , mientras que en Linux es ~/.local/share/godot/Project-Name .

La siguiente línea usa la función to_json() para convertir el diccionario dado en una cadena, que se guarda en la variable json . Luego se llama a la función file store_line() para guardar la cadena dentro del archivo.

Finalmente, llamamos a la función close() para cerrar el archivo y finalizar la operación.

Si quieres saber más sobre cómo usar todas las funciones del sistema de archivos en Godot, puedes consultar mi Guía esencial para la API del sistema de archivos de Godot .

A continuación puedes ver un ejemplo de partida guardada en formato JSON. Formateé el archivo JSON para facilitar la lectura; en realidad se guarda como una sola línea.

{
   "fiona":{
      "necklace_found":true,
      "quest_status":2
   },
   "player":{
      "health":100,
      "health_max":100,
      "health_potions":5,
      "level":3,
      "mana":104.221565,
      "mana_max":200,
      "mana_potions":3,
      "position":[
         256.913422,
         90.034714
      ],
      "xp":250,
      "xp_next_level":400
   },
   "potion1":false,
   "potion2":false,
   "skeletons":[
      {
         "health":100,
         "position":[
            43.824348,
            353.437714
         ]
      },
      {
         "health":100,
         "position":[
            346.069458,
            453.682861
         ]
      },
      {
         "health":100,
         "position":[
            300.874878,
            483.28363
         ]
      },
      {
         "health":100,
         "position":[
            621.517029,
            735.957458
         ]
      },
      {
         "health":100,
         "position":[
            579.305176,
            753.475708
         ]
      },
      {
         "health":100,
         "position":[
            55.37022,
            306.615692
         ]
      },
      {
         "health":100,
         "position":[
            614.484924,
            237.107681
         ]
      },
      {
         "health":100,
         "position":[
            331.521759,
            776.349609
         ]
      },
      {
         "health":100,
         "position":[
            685.134583,
            241.07724
         ]
      },
      {
         "health":100,
         "position":[
            754.878662,
            417.91452
         ]
      },
      {
         "health":100,
         "position":[
            323.712311,
            456.731567
         ]
      },
      {
         "health":100,
         "position":[
            691.614624,
            501.519104
         ]
      },
      {
         "health":100,
         "position":[
            747.091858,
            733.033936
         ]
      },
      {
         "health":100,
         "position":[
            189.58287,
            764.28302
         ]
      },
      {
         "health":100,
         "position":[
            641.547668,
            570.008911
         ]
      }
   ]
}

Cargando el juego desde el archivo guardado

Para cargar el juego desde el archivo guardado, tendremos que hacer el procedimiento inverso al visto anteriormente, convirtiendo el archivo JSON en un diccionario y utilizando este último para restaurar los valores de los distintos nodos. Para hacer esto, crearemos las funciones from_dictionary() para cada nodo que necesite cargarse.

A continuación se enumeran todos los scripts que se modificarán y sus respectivas funciones que se agregarán:

jugador.gd

func from_dictionary(data):
	position = Vector2(data.position[0], data.position[1])
	health = data.health
	health_max = data.health_max
	mana = data.mana
	mana_max = data.mana_max
	xp = data.xp
	xp_next_level = data.xp_next_level
	level = data.level
	health_potions = data.health_potions
	mana_potions = data.mana_potions

Fiona.gd

func from_dictionary(data):
	necklace_found = data.necklace_found
	quest_status = int(data.quest_status)

Esqueleto.gd

func from_dictionary(data):
	position = Vector2(data.position[0], data.position[1])
	health = data.health

EsqueletoSpawner.gd

func from_dictionary(data):
	skeleton_count = data.size()
	for skeleton_data in data:
		var skeleton = skeleton_scene.instance()
		skeleton.from_dictionary(skeleton_data)
		add_child(skeleton)
		skeleton.get_node("Timer").start()
		
		# Connect Skeleton's death signal to the spawner
		skeleton.connect("death", self, "_on_Skeleton_death")

Al cargar el juego desde un archivo guardado, no es necesario crear una instancia de los esqueletos en la función _ready() como cuando se inicia un nuevo juego. Entonces, en SkeletonSpawner.gd, debe cambiar la función _ready() de esta manera:

...

# Create skeletons
if not get_parent().load_saved_game:
	for i in range(start_skeletons):
		instance_skeleton()
	skeleton_count = start_skeletons

...

La carga real del juego tendrá lugar en la función _ready() de SaveLoadGame.gd. Abra el script y agregue la función:

func _ready():
	var file = File.new()
	if load_saved_game and file.file_exists("user://savegame.json"):
		file.open("user://savegame.json", File.READ)
		var data = parse_json(file.get_as_text())
		file.close()
		
		$Player.from_dictionary(data.player)
		$Fiona.from_dictionary(data.fiona)
		$SkeletonSpawner.from_dictionary(data.skeletons)
		if($Fiona.necklace_found):
			$Necklace.queue_free()
		if(not data.potion1):
			$StartPotion1.queue_free()
		if(not data.potion2):
			$StartPotion2.queue_free()

Para cargar el juego, el archivo guardado se abre para lectura, la cadena JSON se lee con el método get_as_text() y se convierte en un diccionario usando la función parse_json . A continuación, usamos las funciones from_dictionary() para reconstruir el estado de los distintos nodos. Finalmente, revisamos algunos valores del diccionario para decidir si eliminar las pociones y el collar de Fiona del árbol de escenas.

¡Y con esto, hemos completado el guardado y la carga del juego!

Pantalla de fin de juego

Lo último que queda por hacer es cambiar la pantalla de Game Over para que nos lleve de vuelta a la pantalla de inicio cuando pulsamos la tecla Escape.

Busque el nodo GameOver y establezca temporalmente su propiedad Modular en ( 255,255,255,255 ) para hacerlo visible. Duplique su etiqueta y muévala hacia abajo, luego cambie el texto a PRESIONE ESC PARA VOLVER AL MENÚ PRINCIPAL y céntrelo horizontalmente.

Establezca Modular en ( 255,255,255,0 ) para ocultarlo nuevamente.

Ahora abra el script MenuPopop.gd y edite la función _input() de esta manera:

func _input(event):
	if not visible:
		if Input.is_action_just_pressed("menu"):
			# If player is dead, go to start screen
			if player.health <= 0:
				get_node("/root/Root").queue_free()
				get_tree().change_scene("res://Scenes/StartScreen.tscn")
				get_tree().paused = false
				return
			# Pause game
			already_paused = get_tree().paused
			get_tree().paused = true
			# Reset the popup
			selected_menu = 0
			change_menu_color()
			# Show popup
			player.set_process_input(false)
			popup()

			...

De esta forma, cuando el jugador esté muerto, pulsar la tecla Escape nos devolverá a la pantalla de inicio.

Conclusiones

En este tutorial aprendimos:

  • cómo cambiar de una escena a otra, usando change_scene() y load() ;
  • cómo preparar datos para guardar, usando diccionarios y formato JSON;
  • cómo guardar y cargar datos hacia y desde el sistema de archivos, usando la clase File , to_json() y parse_json().