Saltar a contenido

Tema 6.1 - Del prototipo al juego: lógica, estados y objetos

En el tema 4 montamos un proyecto que se movía. Eso era un prototipo: demuestra que el motor arranca, pero no es un juego, porque no tiene reglas, ni objetivo, ni final. En este tema convertimos el prototipo en un juego de verdad.

🟢 Leyenda de repaso obligatorio: los textos con este fondo verde son imprescindibles y entran en todas las evaluaciones del módulo.

1. Qué diferencia un prototipo de un juego

Un juego necesita cuatro cosas que un prototipo no tiene:

Pieza Pregunta que responde Ejemplo en nuestro juego
Objetivo ¿qué tengo que conseguir? recoger las 3 monedas
Reglas ¿qué puedo y qué no puedo hacer? solo cuenta si lo toca el jugador
Estado ¿en qué situación está el juego ahora? menú, jugando, terminado
Final ¿cuándo se acaba? cuando quedan 0 monedas

Un juego = objetivo + reglas + estado + final. Sin esas cuatro piezas es una demo técnica.

El resultado de aprendizaje 5 del módulo (RD 405/2023) pide literalmente «desarrollar juegos 2D y 3D sencillos utilizando motores de juegos», y su primer criterio es establecer la lógica de un nuevo juego. Es decir: antes de tocar el editor, se decide la lógica.

2. La lógica se escribe antes que el código

La lógica de un juego se puede contar en una lista numerada, sin nombrar ningún motor:

  1. El jugador aparece en la izquierda de la pantalla.
  2. Hay tres monedas colocadas en el escenario.
  3. El jugador se mueve con las flechas del teclado.
  4. Si el jugador toca una moneda, la moneda desaparece y suma un punto.
  5. Suena un efecto cada vez que se recoge una moneda.
  6. Cuando las tres monedas desaparecen, el juego termina.

Esa lista es el diseño de la lógica (CE a). Si mañana el juego se hace en Unity en vez de en Godot, la lista no cambia: solo cambia el código que la implementa.

La lógica se escribe en lenguaje normal y no menciona el motor. Si tu lista menciona nodos o funciones, todavía no es lógica: es implementación.

3. Máquina de estados: el juego siempre está en un estado

Un juego no es un if gigante. Es una máquina de estados: en cada momento está exactamente en uno de sus estados, y cambia cuando ocurre un evento.

Estado Qué se ve Qué lo cambia
MENU título y botón de jugar pulsar jugar → JUGANDO
JUGANDO el escenario y el marcador recoger la última moneda → GANADO
GANADO mensaje de victoria pulsar reiniciar → JUGANDO

En GDScript se implementa con un enum y una variable de estado:

# Los estados posibles del juego. enum crea constantes con nombre:
# 0 = MENU, 1 = JUGANDO, 2 = GANADO
enum Estado { MENU, JUGANDO, GANADO }

var estado: int = Estado.MENU   # el juego empieza en el menú


func _cambiar_estado(nuevo: int) -> void:
    estado = nuevo                        # guarda el estado nuevo
    match estado:                         # match es el switch de GDScript
        Estado.MENU:
            $HUD/Mensaje.text = "Pulsa JUGAR"
        Estado.JUGANDO:
            $HUD/Mensaje.text = ""        # sin mensaje mientras se juega
        Estado.GANADO:
            $HUD/Mensaje.text = "¡Has ganado!"

Un estado por variable, no por booleano. Tres estados con dos booleanos (jugando y ganado) permiten combinaciones imposibles; un enum no.

Ventaja real de la máquina de estados: cuando el juego crezca (pausa, derrota, tienda), se añade un estado más y no hay que reescribir la lógica entera.

4. Objetos del juego: cada cosa es una escena

El criterio b pide crear los objetos necesarios para el juego y definir sus características. En Godot, un objeto del juego es una escena que se puede instanciar muchas veces:

Objeto Tipo de nodo Características que define
Jugador CharacterBody2D velocidad 320 px/s, forma cuadrada de 48×48
Moneda Area2D forma de 32×32, pertenece al grupo monedas
Marcador Label dentro de un CanvasLayer tamaño de letra 32, texto monedas: 0 / 3
Sonido AudioStreamPlayer fichero sonido.wav

Una moneda es una escena instanciable: si el juego necesita 20 monedas, se instancia 20 veces la misma escena, no se copia y pega el nodo.

El grupo (groups=["monedas"]) es la pieza que hace que el código no dependa de los nombres:

# get_nodes_in_group devuelve TODAS las monedas que existan en la escena,
# sin importar cómo se llamen ni cuántas haya.
var monedas := get_tree().get_nodes_in_group("monedas")
total = monedas.size()                 # 3 en nuestro juego, pero podría ser 30

Agrupar (grupo monedas) es lo que permite añadir monedas en el editor sin tocar una línea de código.

5. Reparto de responsabilidades

El error clásico del principiante es meter todo en un script de 900 líneas. La regla es: cada script hace una cosa.

Script Responsabilidad única
juego2d.gd la lógica: puntuación, estado y fin de partida
jugador.gd moverse y responder a la entrada
moneda.gd avisar de que la han tocado y desaparecer

En nuestro juego el jugador se mueve desde el script principal porque es una sola línea de física, pero en cuanto el jugador tenga salto, animación o vida propia, se lleva a su propio script.

Si un script supera las 150 líneas o mezcla entrada, dibujo y puntuación, está pidiendo una escena propia.

6. El bucle que hace que todo ocurra

La lógica no se ejecuta «cuando quiere»: el motor llama a tus funciones en cada fotograma.

Función Cuándo la llama el motor Para qué sirve
_ready() una vez, al entrar en la escena conectar señales, contar monedas
_process(delta) cada fotograma (dibujo) animaciones, texto del HUD
_physics_process(delta) 60 veces por segundo (física) mover cuerpos, comprobar choques

Todo lo que mueva un cuerpo o compruebe una colisión va en _physics_process, nunca en _process: la física corre a paso fijo y _process no.

La delta es el tiempo en segundos desde el fotograma anterior. Multiplicar por ella hace que la velocidad no dependa de los FPS:

func _physics_process(_delta: float) -> void:
    # get_vector devuelve un vector normalizado (-1..1) según las flechas
    var direccion := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    jugador.velocity = direccion * 320.0   # 320 px/s, no 320 px por fotograma
    jugador.move_and_slide()               # mueve y resuelve las colisiones

direccion * 320.0 con velocity da píxeles por segundo. Si multiplicas además por delta a mano, el jugador irá lentísimo: move_and_slide() ya lo hace.

7. Ejemplo completo y ejecutado: la lógica del juego

Este es el script real de la escena juego2d.tscn del proyecto IsmaelJuego, ejecutado en Godot 4.7.2. Implementa las cinco primeras reglas de la lista del apartado 2 (la sexta, el final de partida, se añade justo después):

extends Node2D
# ============================================================
# UD6 · Juego 2D: movimiento, colisiones, puntuación y sonido
# RA5 · CE a (lógica), b (objetos), c (escenas), e (físicas), f (sonido)
# ============================================================

var puntos: int = 0                                    # monedas recogidas
var total: int = 0                                     # monedas que hay en la escena

@onready var etiqueta: Label = $HUD/Puntos             # texto del marcador
@onready var sonido: AudioStreamPlayer = $Sonido       # efecto al recoger
@onready var jugador: CharacterBody2D = $Jugador       # el personaje


func _ready() -> void:
    # se conecta la señal de cada moneda con la función que suma puntos
    var monedas := get_tree().get_nodes_in_group("monedas")
    total = monedas.size()
    for moneda in monedas:
        moneda.body_entered.connect(_al_recoger.bind(moneda))
    _actualizar_marcador()


func _physics_process(_delta: float) -> void:
    # vector de entrada: flechas o WASD (acciones ui_* que trae Godot)
    var direccion := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    jugador.velocity = direccion * 320.0               # 320 píxeles por segundo
    jugador.move_and_slide()                           # mueve y resuelve colisiones


func _al_recoger(cuerpo: Node2D, moneda: Area2D) -> void:
    if cuerpo != jugador:
        return                                         # solo cuenta si lo toca el jugador
    puntos += 1
    sonido.play()                                      # CE f: sonido en el evento
    moneda.queue_free()                                # la moneda desaparece
    _actualizar_marcador()


func _actualizar_marcador() -> void:
    etiqueta.text = "monedas: %d / %d" % [puntos, total]

@onready retrasa la búsqueda del nodo hasta que la escena existe. Sin @onready, $HUD/Puntos vale null y el juego rompe al arrancar.

Y el final de partida, que es la regla 6, se añade en dos líneas:

func _actualizar_marcador() -> void:
    etiqueta.text = "monedas: %d / %d" % [puntos, total]
    if puntos == total:                    # ya no queda ninguna moneda
        etiqueta.text = "¡Has ganado! %d / %d" % [puntos, total]

La condición de fin se comprueba al cambiar el marcador, no en _process: comprobar lo mismo 60 veces por segundo es trabajo tirado.

8. Documentar el diseño (CE j)

El último criterio del RA5 pide documentar las fases de diseño y desarrollo. No es relleno: es lo que permite que otra persona (o tú en junio) entienda el juego sin abrir el editor. La ficha mínima:

Apartado Qué se escribe
Objetivo recoger las 3 monedas
Reglas movimiento con flechas, solo cuenta el jugador, la moneda desaparece
Estados MENU → JUGANDO → GANADO
Objetos jugador (CharacterBody2D), moneda (Area2D), HUD, sonido
Controles flechas del teclado
Final marcador 3/3

9. Errores típicos (y cómo se ven)

Error Qué pasa en pantalla Causa
Todo en _process el jugador atraviesa las monedas la física no se resuelve a paso fijo
Comparar por nombre de nodo añades una moneda y no cuenta falta el grupo monedas
queue_free() y seguir usando el nodo error «previously freed instance» se libera y se vuelve a tocar
Sumar puntos sin comprobar quién entra suma al pasar cualquier cuerpo falta if cuerpo != jugador

«previously freed instance» significa que has usado un nodo después de queue_free(). Se arregla comprobando is_instance_valid(moneda) o no volviendo a tocarlo.

Bibliografía oficial

Presentación