Sistema de juego: puntuación, vida, reinicio y condiciones de victoria

Capítulo 10

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

Objetivo del sistema de juego

En este capítulo vas a implementar un ciclo de juego completo y coherente: el jugador tiene vida, recolecta monedas que suman puntos, puede reiniciar al caer o recibir daño, y puede ganar al llegar a una meta. Para que el proyecto sea escalable, centralizaremos el estado en un GameManager y conectaremos el resto del juego mediante señales. La interfaz (UI) se actualizará automáticamente cuando cambien vida, monedas o puntuación.

Arquitectura recomendada (quién hace qué)

  • GameManager: guarda el estado (vida, monedas, puntos), aplica reglas (daño, sumar puntos, victoria, reinicio) y emite señales para la UI.
  • Jugador: detecta eventos (recibe daño, cae) y notifica al GameManager; no decide reglas globales.
  • Moneda (Area2D): al ser recogida emite una señal o llama al GameManager para sumar.
  • Meta (Area2D): al tocarla, notifica victoria al GameManager.
  • UI (Control): muestra vida/monedas/puntos y mensajes; escucha señales del GameManager.

Paso a paso: crear el GameManager (Autoload)

1) Script GameManager.gd

Crea un script llamado GameManager.gd y añádelo como Autoload (Project Settings > Autoload). Así estará disponible como singleton en todo el juego.

extends Node

signal stats_changed(lives: int, coins: int, score: int)
signal game_over()
signal victory()
signal respawned()

@export var max_lives := 3
@export var points_per_coin := 10

var lives: int
var coins: int
var score: int

var spawn_position: Vector2
var current_level_path: String

func _ready() -> void:
	reset_run()

func reset_run() -> void:
	lives = max_lives
	coins = 0
	score = 0
	emit_signal("stats_changed", lives, coins, score)

func register_level(level_node: Node) -> void:
	# Guarda la escena actual para poder recargarla en reinicios “duros”.
	current_level_path = level_node.scene_file_path

func register_spawn(pos: Vector2) -> void:
	spawn_position = pos

func add_coin(amount := 1) -> void:
	coins += amount
	score += amount * points_per_coin
	emit_signal("stats_changed", lives, coins, score)

func damage(amount := 1) -> void:
	lives -= amount
	lives = max(lives, 0)
	emit_signal("stats_changed", lives, coins, score)
	if lives <= 0:
		emit_signal("game_over")
		return
	respawn_player()

func fell_out_of_world() -> void:
	# Caer puede contar como daño o reinicio directo; aquí lo tratamos como daño.
	damage(1)

func respawn_player() -> void:
	# Este método no mueve al jugador directamente si no lo tienes referenciado.
	# En su lugar, emitimos una señal para que el nivel/jugador reaccione.
	emit_signal("respawned")

func win() -> void:
	emit_signal("victory")

func hard_restart_level() -> void:
	# Recarga completa de la escena actual (útil para restablecer monedas/enemigos).
	if current_level_path != "":
		get_tree().change_scene_to_file(current_level_path)

2) ¿Por qué señales desde GameManager?

Porque la UI y el nivel no deberían estar consultando constantemente valores (polling). Con señales, el GameManager “empuja” cambios: cada vez que cambian vida/monedas/puntos, la UI se actualiza; cuando hay victoria o game over, el nivel puede pausar, mostrar paneles, etc.

UI: nodos y actualización automática

1) Estructura de UI (Control)

Crea una escena HUD.tscn con raíz Control. Ejemplo de jerarquía:

  • HUD (Control)
    • TopBar (HBoxContainer)
      • LifeIcon (TextureRect)
      • LivesLabel (Label)
      • CoinIcon (TextureRect)
      • CoinsLabel (Label)
      • ScoreLabel (Label)
    • CenterMessage (Label) (opcional para “Victoria” / “Game Over”)

Asigna texturas a los TextureRect (icono de corazón, moneda) y deja los Label listos para texto.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

2) Script del HUD escuchando al GameManager

extends Control

@onready var lives_label: Label = $TopBar/LivesLabel
@onready var coins_label: Label = $TopBar/CoinsLabel
@onready var score_label: Label = $TopBar/ScoreLabel
@onready var center_message: Label = $CenterMessage

func _ready() -> void:
	center_message.visible = false
	GameManager.stats_changed.connect(_on_stats_changed)
	GameManager.game_over.connect(_on_game_over)
	GameManager.victory.connect(_on_victory)
	# Forzamos una primera actualización (por si el HUD aparece después)
	_on_stats_changed(GameManager.lives, GameManager.coins, GameManager.score)

func _on_stats_changed(lives: int, coins: int, score: int) -> void:
	lives_label.text = str(lives)
	coins_label.text = str(coins)
	score_label.text = "Puntos: %s" % score

func _on_game_over() -> void:
	center_message.visible = true
	center_message.text = "Game Over"

func _on_victory() -> void:
	center_message.visible = true
	center_message.text = "Victoria"

Importante: el HUD no calcula nada; solo refleja el estado y reacciona a eventos.

Monedas: recolección con señales

1) Escena Coin.tscn (Area2D)

Crea una moneda como Area2D con CollisionShape2D y un Sprite2D. Configura la colisión para detectar al jugador. Puedes usar un grupo player en el nodo del jugador para identificarlo.

2) Script de la moneda

extends Area2D

signal collected

@export var coin_value := 1

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		emit_signal("collected")
		GameManager.add_coin(coin_value)
		queue_free()

Aquí se muestran dos enfoques a la vez: la moneda emite collected (útil si quieres efectos locales) y además notifica al GameManager para sumar. Si prefieres desacoplar más, puedes conectar collected desde el nivel y que el nivel llame a GameManager.add_coin().

Daño y reinicio: señal “jugador herido” y caída

1) Jugador: emitir “herido” al recibir daño

En tu jugador, añade una señal y un método para ser dañado. La fuente de daño (enemigo, pinchos, proyectil) solo llama a player.take_damage().

# En el script del jugador
signal hurt(amount: int)

var invulnerable := false

func take_damage(amount := 1) -> void:
	if invulnerable:
		return
	invulnerable = true
	emit_signal("hurt", amount)
	# Ventana de invulnerabilidad simple
	await get_tree().create_timer(0.8).timeout
	invulnerable = false

2) Nivel: conectar la señal del jugador al GameManager

En la escena del nivel (por ejemplo Level01.tscn), conecta el jugador al GameManager. Esto mantiene al jugador sin lógica global.

# Script del nivel (Node2D)
@onready var player = $Player
@onready var spawn = $SpawnPoint

func _ready() -> void:
	GameManager.register_level(self)
	GameManager.register_spawn(spawn.global_position)
	player.hurt.connect(_on_player_hurt)
	GameManager.respawned.connect(_on_respawned)

func _on_player_hurt(amount: int) -> void:
	GameManager.damage(amount)

func _on_respawned() -> void:
	# Respawn “suave”: reposicionar y limpiar velocidad/estado.
	player.global_position = GameManager.spawn_position
	if player.has_method("set_velocity"):
		player.set_velocity(Vector2.ZERO)
	# Alternativa típica si es CharacterBody2D:
	if "velocity" in player:
		player.velocity = Vector2.ZERO

Nota: si tu jugador es CharacterBody2D, normalmente basta con player.velocity = Vector2.ZERO. Ajusta según tu implementación.

3) Caída fuera del mundo (DeathZone)

Crea un Area2D grande debajo del nivel llamado DeathZone con colisión. Cuando el jugador entra, se considera caída.

# Script en DeathZone (Area2D)
func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		GameManager.fell_out_of_world()

Esto evita depender de “si y < -1000” y hace el nivel más controlable.

Condición de victoria: llegar a una meta

1) Escena Goal.tscn (Area2D)

Crea una meta como Area2D con su colisión. Al tocarla el jugador, se dispara la victoria.

extends Area2D

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		GameManager.win()

2) Reacción del nivel ante victoria

Decide qué ocurre al ganar: pausar, mostrar panel, bloquear input, pasar de nivel. Aquí haremos algo simple: pausar y mostrar el mensaje del HUD (ya conectado).

# En el script del nivel
func _ready() -> void:
	GameManager.victory.connect(_on_victory)
	GameManager.game_over.connect(_on_game_over)

func _on_victory() -> void:
	get_tree().paused = true

func _on_game_over() -> void:
	get_tree().paused = true

Si pausas el árbol, recuerda que algunos nodos UI podrían necesitar process_mode en Always si quieres animaciones; para texto estático no hace falta.

Reinicio: suave vs recarga de escena

Reinicio suave (recomendado para “perder una vida”)

  • Reposiciona al jugador en SpawnPoint.
  • Resetea velocidad y estados temporales (invulnerabilidad, salto, etc.).
  • No recrea monedas ya recogidas (se mantienen recogidas).

Ya lo implementaste con GameManager.respawned y el handler del nivel.

Reinicio duro (útil para “Game Over” o botón de reinicio)

Recarga la escena del nivel para restablecer monedas/enemigos/objetos a su estado original. Puedes hacerlo al recibir game_over o desde un botón en UI.

# Ejemplo: botón en UI (Control) para reiniciar
func _on_restart_button_pressed() -> void:
	get_tree().paused = false
	GameManager.reset_run()
	GameManager.hard_restart_level()

Si usas reinicio duro, asegúrate de llamar a reset_run() para restaurar vida/monedas/puntos.

Pruebas rápidas (checklist) para validar el sistema

1) La UI refleja cambios

  • Al iniciar el nivel, LivesLabel muestra max_lives, monedas 0 y puntos 0.
  • Al recoger 1 moneda: monedas +1, puntos +points_per_coin.
  • Al recoger varias monedas: el conteo coincide con el número recogido y el puntaje escala correctamente.

2) Daño y respawn

  • Al recibir daño: la vida baja en 1 y la UI se actualiza inmediatamente.
  • Si la vida sigue > 0: el jugador reaparece en el SpawnPoint y su velocidad queda en cero (no “sale disparado”).
  • Si hay invulnerabilidad: dos golpes seguidos dentro de 0.8s no deben restar dos vidas.

3) Caída fuera del mundo

  • Al entrar en DeathZone: se aplica la misma regla que daño (vida -1 y respawn).
  • Repite hasta llegar a 0 vidas: debe dispararse game_over y mostrarse el mensaje correspondiente.

4) Reinicio restablece el estado

  • Tras Game Over, al ejecutar reinicio duro: vida vuelve a max_lives, monedas 0, puntos 0.
  • Las monedas vuelven a aparecer (porque la escena se recargó).
  • La UI vuelve a los valores iniciales sin necesidad de “refrescar manualmente”.

5) Victoria

  • Al tocar la meta: se emite victory, aparece el mensaje en UI y el juego se pausa (o se bloquea el control, según tu decisión).
  • Verifica que no se pueda seguir sumando monedas o recibiendo daño si el juego está pausado (si no pausas, desactiva colisiones o input).

Tabla de señales y conexiones sugeridas

EmisorSeñalReceptorUso
GameManagerstats_changedHUDActualizar labels de vida/monedas/puntos
GameManagerrespawnedNivelReposicionar jugador en spawn
GameManagergame_overHUD / NivelMostrar mensaje y pausar / reiniciar
GameManagervictoryHUD / NivelMostrar mensaje y pausar / cambiar de nivel
JugadorhurtNivelDelegar daño al GameManager
Monedacollected(Opcional) NivelEfectos locales; el puntaje lo maneja GameManager

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la razón principal para que el GameManager emita señales (por ejemplo stats_changed) en lugar de que la UI consulte constantemente los valores de vida, monedas y puntos?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Las señales permiten que el GameManager “empuje” los cambios de estado a la UI y al nivel cuando ocurren, evitando el polling y manteniendo responsabilidades separadas: el GameManager gestiona reglas/estado y la UI solo refleja.

Siguiente capítulo

Audio y retroalimentación para un juego 2D en Godot

Arrow Right Icon
Portada de libro electrónico gratuitaGodot desde Cero: Crea tu Primer Juego 2D con GDScript
83%

Godot desde Cero: Crea tu Primer Juego 2D con GDScript

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.