tutoriales.com

Creando un Sistema de Guardado y Carga de Partidas en Godot 4: Persistencia para tus Juegos

Este tutorial te guiará paso a paso en la creación de un sistema de guardado y carga de partidas en Godot 4. Aprenderás a serializar datos, guardarlos en archivos y recuperarlos para que los jugadores nunca pierdan su progreso. ¡Haz tus juegos realmente persistentes!

Intermedio15 min de lectura9 views
Reportar error
Creando un Sistema de Guardado y Carga de Partidas en Godot 4: Persistencia para tus Juegos

🚀 Introducción: La Importancia del Guardado y Carga de Partidas

¿Qué sería de un juego si cada vez que el jugador lo cierra, pierde todo su progreso? Frustrante, ¿verdad? Un sistema de guardado y carga de partidas es fundamental en casi cualquier videojuego. Permite a los jugadores continuar su aventura donde la dejaron, manteniendo su inventario, estadísticas, posición en el mapa y cualquier otro dato relevante del estado del juego.

En este tutorial, exploraremos cómo implementar un sistema robusto y flexible de guardado y carga en Godot 4. Nos centraremos en la serialización de datos, la gestión de archivos y la integración de este sistema con diferentes aspectos de tu juego.

💡 Consejo: Un buen sistema de guardado no solo es funcional, sino también intuitivo para el jugador. Considera la experiencia de usuario desde el principio.

🎯 Objetivos del Tutorial

Al finalizar este tutorial, serás capaz de:

  • Entender los conceptos básicos de persistencia de datos en Godot.
  • Utilizar la clase FileAccess para leer y escribir archivos.
  • Serializar y deserializar datos de juego complejos (diccionarios, arrays, objetos Godot).
  • Diseñar una estructura para los datos a guardar.
  • Implementar funciones de guardado y carga automáticas y manuales.
  • Manejar posibles errores durante el proceso de E/S de archivos.
100% Completo

📖 Conceptos Fundamentales de Persistencia

Antes de sumergirnos en el código, es crucial entender algunos conceptos:

  • Serialización: El proceso de convertir un objeto o estructura de datos en un formato que puede ser almacenado (por ejemplo, en un archivo) o transmitido. En Godot, esto a menudo implica convertir tipos de datos complejos (como Vector2, Color, objetos personalizados) en tipos básicos que pueden ser guardados directamente (números, cadenas, booleanos, arrays, diccionarios).
  • Deserialización: El proceso inverso a la serialización; reconstruir un objeto o estructura de datos a partir de su representación almacenada.
  • Ruta de Archivo: La ubicación donde se guardará o leerá el archivo. En Godot, es común usar user:// para rutas específicas del usuario, que apuntan a un directorio persistente para la aplicación en el sistema operativo del usuario. Esto es crucial para la portabilidad y para evitar permisos de escritura en directorios del sistema.

💾 Formatos de Archivo para Guardar Datos

Existen varios formatos que podemos utilizar para guardar nuestros datos. Los más comunes son:

FormatoDescripciónVentajasDesventajas
------------
JSONFormato de texto ligero y legible por humanos, basado en pares clave-valor.Legible, fácil de parsear en muchos lenguajes.Puede ser menos eficiente para datos muy grandes o binarios.
CSVFormato de texto simple para datos tabulares, separados por comas.Muy simple, ideal para tablas de datos sencillas.No adecuado para estructuras de datos complejas o jerárquicas.
------------
BINARIODatos almacenados en formato binario puro.Muy eficiente en espacio y velocidad de lectura/escritura.No legible por humanos, más complejo de implementar.
Godot (Config)ConfigFile de Godot, similar a un archivo INI.Nativo de Godot, ideal para configuraciones simples.Limitado para datos de juego complejos, no soporta arrays anidados.

Para este tutorial, utilizaremos JSON debido a su equilibrio entre legibilidad y facilidad de uso con los tipos de datos nativos de Godot (especialmente diccionarios y arrays).


🛠️ Configurando el Proyecto Godot

Comienza creando un nuevo proyecto Godot 4. No necesitamos nada complejo para el escenario, simplemente una escena base para probar el guardado.

  1. Crea una nueva escena 2D (o 3D si prefieres). Llama al nodo raíz Main. Guarda esta escena como main_scene.tscn.
  2. Añade algunos nodos de UI para interactuar con nuestro sistema de guardado:
    • Un Button llamado GuardarButton (Texto: "Guardar Partida")
    • Un Button llamado CargarButton (Texto: "Cargar Partida")
    • Un Label llamado MensajeLabel para mostrar el estado del guardado/carga.
    • Un LineEdit llamado NombreJugadorLineEdit para guardar el nombre del jugador.
    • Un SpinBox llamado PuntosVidaSpinBox para guardar puntos de vida.
    • Un Button llamado IncrementarPuntosButton (Texto: "+1 Vida").

Organiza estos nodos de forma que sean visibles y accesibles en la escena.

Escena: Main.tscn Main (Node2D) VBoxContainer NombreJugadorLineEdit (LineEdit) PuntosVidaSpinBox (SpinBox) IncrementarPuntosButton (Button) GuardarButton (Button) CargarButton (Button) MensajeLabel (Label) Jerarquía de Nodos Godot

⚙️ Creando el Script de Guardado y Carga (SaveLoadManager.gd)

Crearemos un singleton (Autoload) para gestionar el guardado y la carga globalmente en nuestro juego. Esto significa que podemos acceder a sus funciones desde cualquier parte sin tener que instanciarlo.

  1. Crea un nuevo script GDScript llamado SaveLoadManager.gd en tu carpeta res://scripts/.
  2. Actívalo como Autoload: Ve a Proyecto -> Ajustes del Proyecto -> Autoload. Haz clic en el icono de carpeta para seleccionar SaveLoadManager.gd, asígnale el nombre SaveLoadManager y haz clic en Añadir.

Ahora, abramos SaveLoadManager.gd y empecemos a programar.

extends Node

const SAVE_FILE_NAME = "user://savegame.json"

# --- Propiedades de ejemplo para guardar ---
var player_name: String = "Jugador_Default"
var health: int = 100
var level: int = 1
var inventory: Array[String] = ["Espada", "Poción", "Escudo"]
var player_position: Vector2 = Vector2(0, 0)

# --- Función para obtener los datos del juego actuales ---
func _get_game_data() -> Dictionary:
    # Esta función debería recolectar el estado actual de tu juego
    # Aquí usamos las propiedades de ejemplo del manager para simplificar
    # En un juego real, obtendrías estos datos de los nodos correspondientes
    return {
        "player_name": player_name,
        "health": health,
        "level": level,
        "inventory": inventory,
        "player_position": player_position
    }

# --- Función para aplicar los datos cargados al juego ---
func _apply_game_data(data: Dictionary):
    player_name = data.get("player_name", "")
    health = data.get("health", 0)
    level = data.get("level", 0)
    inventory = data.get("inventory", [])
    player_position = data.get("player_position", Vector2(0, 0))
    
    print("Datos aplicados: ", data)
    emit_signal("game_data_loaded") # Emitimos una señal para que otros nodos actualicen su UI/estado

# --- Señal para notificar que los datos han sido cargados ---
signal game_data_loaded

# --- GUARDAR PARTIDA ---
func save_game() -> Error:
    var data_to_save: Dictionary = _get_game_data()
    
    # Convertimos el diccionario a una cadena JSON
    var json_string = JSON.stringify(data_to_save, "  ") # "  " para pretty-print
    
    var file = FileAccess.open(SAVE_FILE_NAME, FileAccess.WRITE)
    if file == null:
        print_error("Error al abrir archivo para guardar: ", FileAccess.get_open_error())
        return FileAccess.get_open_error()

    file.store_string(json_string)
    file.close()
    
    print("Partida guardada exitosamente en ", SAVE_FILE_NAME)
    return OK

# --- CARGAR PARTIDA ---
func load_game() -> Error:
    if not FileAccess.file_exists(SAVE_FILE_NAME):
        print_error("Archivo de guardado no encontrado: ", SAVE_FILE_NAME)
        return ERR_FILE_NOT_FOUND
        
    var file = FileAccess.open(SAVE_FILE_NAME, FileAccess.READ)
    if file == null:
        print_error("Error al abrir archivo para cargar: ", FileAccess.get_open_error())
        return FileAccess.get_open_error()
        
    var content = file.get_as_text()
    file.close()
    
    var json_parse_result = JSON.parse_string(content)
    if json_parse_result == null:
        print_error("Error al parsear JSON del archivo de guardado.")
        return ERR_PARSE_ERROR
        
    var loaded_data: Dictionary = json_parse_result
    _apply_game_data(loaded_data)
    
    print("Partida cargada exitosamente desde ", SAVE_FILE_NAME)
    return OK

# --- Eliminar Archivo de Guardado (opcional, para debug) ---
func delete_save_file():
    if FileAccess.file_exists(SAVE_FILE_NAME):
        DirAccess.remove_absolute(SAVE_FILE_NAME)
        print("Archivo de guardado eliminado.")
    else:
        print("No hay archivo de guardado para eliminar.")

📝 Explicación del Código SaveLoadManager.gd

  • SAVE_FILE_NAME: Define la ruta del archivo de guardado. user:// es una ruta especial que Godot resuelve a un directorio de datos persistente para tu aplicación, ideal para guardar datos de usuario.
  • Propiedades de Ejemplo: player_name, health, level, inventory, player_position son datos de ejemplo que queremos guardar. En un juego real, estos datos provendrían de nodos específicos (un nodo de jugador, un gestor de inventario, etc.).
  • _get_game_data(): Esta función es clave. Es la encargada de recolectar todos los datos del estado actual del juego que deseamos guardar. Los agrupa en un Dictionary que es fácilmente serializable a JSON.
    • Importante: Si tu juego tiene nodos con propiedades dinámicas, necesitarás iterar sobre ellos y recoger sus datos. Por ejemplo, for item in inventory_manager.get_items(): data["inventory_items"].append(item.to_dictionary()).
  • _apply_game_data(data: Dictionary): Hace lo opuesto. Toma un diccionario de datos cargados y los aplica al estado del juego. Esto implica actualizar las propiedades del jugador, el inventario, la posición de los enemigos, etc.
  • save_game(): Abre el archivo de guardado en modo escritura (FileAccess.WRITE). Serializa el diccionario de datos a una cadena JSON usando JSON.stringify() y la guarda en el archivo. Es crucial manejar errores si el archivo no puede ser abierto.
  • load_game(): Primero verifica si el archivo existe. Luego, lo abre en modo lectura (FileAccess.READ), lee todo su contenido como texto, y lo parsea de JSON a un diccionario de Godot usando JSON.parse_string(). Finalmente, llama a _apply_game_data() para restaurar el estado del juego.
  • game_data_loaded señal: Esta señal es muy útil. Permite que otros nodos en tu juego (como la UI) sepan cuándo se han cargado nuevos datos y necesitan actualizarse.
⚠️ Advertencia: Siempre maneja los errores al trabajar con archivos. Los problemas de E/S son comunes y pueden causar que tu juego crashee si no se gestionan adecuadamente.

🎮 Integrando el Sistema en la Escena Principal (main_scene.gd)

Ahora, vamos a conectar nuestra UI en main_scene.tscn con las funciones del SaveLoadManager.

  1. Adjunta un nuevo script a tu nodo Main llamado main_scene.gd.
  2. Conecta las señales de los botones:
    • GuardarButton -> pressed() a Main
    • CargarButton -> pressed() a Main
    • IncrementarPuntosButton -> pressed() a Main
    • También conecta la señal game_data_loaded del SaveLoadManager a una función en Main.
extends Node2D

@onready var player_name_line_edit = $VBoxContainer/NombreJugadorLineEdit
@onready var health_spin_box = $VBoxContainer/PuntosVidaSpinBox
@onready var message_label = $VBoxContainer/MensajeLabel

func _ready():
    # Inicializamos la UI con los datos actuales del manager (o defaults)
    _update_ui()
    # Conectamos a la señal del SaveLoadManager para actualizar la UI cuando se carguen datos
    SaveLoadManager.game_data_loaded.connect(_on_game_data_loaded)

func _update_ui():
    player_name_line_edit.text = SaveLoadManager.player_name
    health_spin_box.value = SaveLoadManager.health
    message_label.text = "Estado actual: " + SaveLoadManager.player_name + ", Vida: " + str(SaveLoadManager.health)

func _on_GuardarButton_pressed():
    # Primero, actualizamos los datos en el manager con lo que hay en la UI
    SaveLoadManager.player_name = player_name_line_edit.text
    SaveLoadManager.health = int(health_spin_box.value)
    
    var error = SaveLoadManager.save_game()
    if error == OK:
        message_label.text = "Partida guardada exitosamente!"
    else:
        message_label.text = "Error al guardar partida: " + str(error)

func _on_CargarButton_pressed():
    var error = SaveLoadManager.load_game()
    if error == OK:
        message_label.text = "Partida cargada exitosamente!"
        _update_ui() # La UI se actualizará automáticamente via la señal, pero lo forzamos aquí también si la señal aún no está conectada
    else:
        message_label.text = "Error al cargar partida: " + str(error)

func _on_IncrementarPuntosButton_pressed():
    SaveLoadManager.health += 1
    _update_ui()

func _on_game_data_loaded():
    print("Señal 'game_data_loaded' recibida en Main. Actualizando UI.")
    _update_ui()

📝 Explicación del Código main_scene.gd

  • @onready var: Obtiene referencias a los nodos de la UI para poder interactuar con ellos.
  • _ready(): Se encarga de inicializar la UI con los datos actuales (por defecto o cargados si es un juego que carga al inicio) y conecta la señal game_data_loaded del SaveLoadManager.
  • _update_ui(): Es una función de ayuda que toma los datos del SaveLoadManager y actualiza los elementos de la UI (LineEdit, SpinBox, Label).
  • _on_GuardarButton_pressed(): Antes de llamar a SaveLoadManager.save_game(), es crucial recoger los datos actuales de la UI (como el nombre del jugador o la vida) y asignárselos a las propiedades del SaveLoadManager.
  • _on_CargarButton_pressed(): Llama a SaveLoadManager.load_game(). Una vez que los datos se cargan y se aplican en el SaveLoadManager, se emite la señal game_data_loaded, lo que a su vez llama a _on_game_data_loaded() en esta escena para actualizar la UI.
  • _on_IncrementarPuntosButton_pressed(): Simplemente incrementa los puntos de vida del SaveLoadManager y actualiza la UI.

✨ Mejoras Avanzadas y Consideraciones

Este sistema es una base sólida, pero puedes expandirlo con varias mejoras:

🔒 Seguridad y Encriptación

Para juegos donde la manipulación de los archivos de guardado podría ser un problema (por ejemplo, para evitar trampas), considera encriptar o ofuscar los datos. Godot ofrece herramientas como HashingContext o puedes implementar tus propios algoritmos XOR simples para dificultar la lectura directa del JSON.

Ejemplo de Ofuscación Básica (No es encriptación real)
# En SaveLoadManager.gd
func _obfuscate_string(input_string: String, key: int) -> String:
    var obfuscated_chars = []
    for char_code in input_string.to_utf8_buffer():
        obfuscated_chars.append(char_code ^ key) # XOR con una clave
    return obfuscated_chars.hex_encode()

func _deobfuscate_string(obfuscated_hex: String, key: int) -> String:
    var deobfuscated_chars = []
    var buffer = ParsedJSON.new()
    buffer.parse(obfuscated_hex.hex_decode())
    for byte_value in buffer:
        deobfuscated_chars.append(byte_value ^ key)
    return String.new().parse_utf8(PackedByteArray(deobfuscated_chars))

# Usa estas funciones en save_game y load_game
# var json_string = _obfuscate_string(JSON.stringify(data_to_save), 123)
# var content = _deobfuscate_string(file.get_as_text(), 123)

🔄 Múltiples Slots de Guardado

En lugar de un único archivo savegame.json, puedes permitir que los jugadores tengan múltiples ranuras de guardado (savegame_slot1.json, savegame_slot2.json, etc.). Esto implicaría pasar un parámetro slot_number a las funciones save_game() y load_game().

Paso 1: Recopilar datos específicos de la partida actual.
Paso 2: Pedir al jugador que elija un slot (ej. 'Slot A', 'Slot B').
Paso 3: Construir el nombre del archivo con el ID del slot: `user://savegame_slotA.json`.
Paso 4: Guardar/Cargar el archivo correspondiente.

📦 Guardado Automático

Implementa un guardado automático periódico (cada X minutos) o en puntos clave del juego (al pasar de nivel, al entrar en una nueva área). Esto se puede lograr con un Timer en el SaveLoadManager.

🔥 Importante: Para evitar pérdidas de datos en caso de un fallo durante el guardado, considera usar un enfoque de "guardado seguro": primero guarda en un archivo temporal (`temp_save.json`), si el guardado es exitoso, borra el archivo anterior y renombra el temporal al nombre final (`savegame.json`).

🧩 Guardado de Objetos Complejos (Nodos, Recursos)

Guardar la posición de un Vector2 o un Color es sencillo porque JSON lo maneja bien con arrays [x, y] o [r, g, b, a]. Pero ¿qué pasa con objetos más complejos como enemigos con estados, recursos o inventarios complejos?

  • Nodos: En lugar de guardar los propios nodos, guarda sus propiedades clave (posición, vida, ID de tipo de enemigo, inventario). Al cargar, recrea los nodos y aplica esas propiedades.
  • Recursos (Resources): Algunos recursos pueden ser serializados. Si tienes un recurso PlayerStats.tres, puedes guardar las propiedades de ese recurso en tu diccionario de guardado. Al cargar, creas un nuevo recurso de ese tipo y le asignas las propiedades.
# Ejemplo de cómo un objeto personalizado podría serializarse
class_name EnemyData
extends RefCounted # O Resource

var id: String
var position: Vector2
var health: int
var defeated: bool

func to_dictionary():
    return {
        "id": id,
        "position": position.x,
        "position_y": position.y, # Godot JSON serializa Vector2 a array, pero aquí lo hacemos explícito
        "health": health,
        "defeated": defeated
    }

static func from_dictionary(data: Dictionary) -> EnemyData:
    var enemy_data = EnemyData.new()
    enemy_data.id = data.get("id", "")
    enemy_data.position = Vector2(data.get("position_x", 0), data.get("position_y", 0))
    enemy_data.health = data.get("health", 0)
    enemy_data.defeated = data.get("defeated", false)
    return enemy_data

# En _get_game_data():
# var enemies_to_save = []
# for enemy in get_tree().get_nodes_in_group("enemies"):
#     enemies_to_save.append(enemy.get_save_data())
# data["enemies"] = enemies_to_save

# En _apply_game_data():
# for enemy_data_dict in data.get("enemies", []):
#     var enemy_data = EnemyData.from_dictionary(enemy_data_dict)
#     # Recrear el enemigo en el juego y aplicar enemy_data

📝 Debugging y Archivos de Guardado

Durante el desarrollo, querrás inspeccionar tus archivos de guardado. Recuerda que la ruta user:// se mapea a diferentes lugares según el sistema operativo:

  • Windows: %APPDATA%/Godot/app_userdata/<your_project_name>
  • Linux: ~/.local/share/godot/app_userdata/<your_project_name>
  • macOS: ~/Library/Application Support/Godot/app_userdata/<your_project_name>

Puedes imprimir OS.get_user_data_dir() para ver la ruta exacta en tiempo de ejecución.


✅ Conclusión

Has aprendido a construir un sistema robusto de guardado y carga de partidas en Godot 4 utilizando archivos JSON. Este es un componente esencial para la mayoría de los juegos, y dominarlo te permitirá crear experiencias de juego mucho más ricas y persistentes para tus jugadores. Experimenta con diferentes tipos de datos y complejidades para adaptar este sistema a las necesidades específicas de tu proyecto.

¡Ahora puedes garantizar que tus jugadores nunca perderán su progreso y siempre podrán retomar la aventura!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!