Tu primer juego

Sinopsis

Este tutorial te guiará en la creación de tu primer proyecto en Godot. Aprenderás cómo funciona el editor, estructurar un proyecto y construir un juego 2D.

Nota

Este proyecto es una introducción al motor Godot. Se asume que el lector posee alguna experiencia previa en programación. Sí estás empezando desde cero a programar, deberías empezar por aquí : Scripting.

El juego se llama "Dodge the Creeps!". El personaje debe moverse y esquivar los enemigos tanto tiempo como sea posible. Aquí hay una vista previa del resultado final:

../../_images/dodge_preview.gif

¿Por qué 2D? Los juegos 3D son mucho más complejos que los 2D. Debes permanecer en 2D hasta que hayas adquirido un conocimiento suficiente del proceso de desarrollo de un videojuego y de cómo utilizar Godot.

Configuración del proyecto

Ejecuta Godot y crea un proyecto nuevo. Luego descarga dodge_assets.zip. Esto contiene las imágenes y sonidos que se utilizarán para hacer el juego. Descomprime los archivos en la carpeta del proyecto.

Nota

Para este tutorial, asumiremos que ya estás familiarizado con el editor. Si no te leiste Escenas y nodos, este es el momento de hacerlo para saber cómo configurar un proyecto y utilizar el editor.

Este juego utiliza el modo vertical (portrait), así que tendremos que ajustar el tamaño de la ventana. Haz clic en Proyecto -> Ajustes del proyecto -> Display -> Window y colocar "Width" en 480 y "Height" en 720.

También en esta sección, bajo la opción "Stretch", coloca Mode a "2D y Aspect a "keep". Esto te asegura que el juego escala consistentemente en diferentes tamaños de pantalla.

Organizando el proyecto

En este proyecto, haremos 3 escenas independientes: Player (jugador), Mob (enemigo), y HUD (visualización del estado del juego), los que se combinarán en la escena Main (principal). En un proyecto grande, puede ser útil crear carpetas que contengan las escenas y sus scripts, pero para este juego relativamente pequeño se pueden grabar las escenas y scripts en la carpeta raíz, normalmente referida como res://. Se pueden ver las carpetas en el Panel de Sistema de Archivos, en la parte superior izquierda:

../../_images/filesystem_dock.png

Escena del jugador

La primer escena que haremos define el objeto Player. Uno de los beneficios de crear una escena del jugador aparte es que se puede probar de manera separada, aún antes de haber creado otras partes del juego.

Estructura de nodos

Para empezar, necesitamos elegir un nodo raíz para el objeto jugador. Como regla general, el nodo raíz de una escena debe reflejar la funcionalidad deseada del objeto - lo que el objeto es. Haz clic en el botón "Otro Nodo" y añade un nodo Area2D a la escena.

../../_images/add_node.png

Godos mostrará un ícono de advertencia junto al nodo en el árbol de la escena. Puedes ignorarlo por ahora, lo resolveremos más adelante.

Con Area2D podemos detectar objetos que se superponen o entran en contacto con el jugador. Cambia su nombre a Player haciendo clic en el nombre del nodo. Este es el nodo raíz de la escena. Podemos añadir nodos adicionales al player para añadir funcionalidad.

Antes de que agreguemos un hijo al nodo Player, debemos asegurarnos de no moverlos o escalarlos accidentalmente al hacer clic en ellos. Selecciona el nodo y haz clic en el icono a la derecha del candado; su descripción dice "Hace que los hijos de un nodo no sean seleccionables".

../../_images/lock_children.png

Guarda la escena. Haz clic en Escenas -> Guardar escena, o presiona Ctrl+S en Windows/Linux o Comando + S en Mac.

Nota

Para este proyecto, seguiremos las convenciones de nomenclatura de Godot.

  • GDScript: Clases (nodos) usan PascalCase, variables y funciones utilizan snake_case, y las constantes TODO_MAYUSCULAS (Lee Guía de estilo de GDScript).
  • C#: Las clases, las variables de exportación y los métodos usan PascalCase, los campos privados usan _camelCase, y las variables y parámetros locales usan camelCase (Ver Guía de estilo de C#). Ten cuidado de escribir los nombres de los métodos de manera precisa al conectar señales.

Animación del sprite

Haz clic en el nodo Player y añade un nodo AnimatedSprite como hijo. El AnimatedSprite se encargará de la apariencia y las animaciones de nuestro jugador. Observa que hay un símbolo de advertencia junto al nodo. Un AnimatedSprite requiere un recurso SpriteFrames, compuesto por una lista de las animaciones que puede mostrar. Para crear uno, busca la propiedad Frames en el Inspector y haz clic en "[empty]" -> "New SpriteFrames". Clic de nuevo para abrir el panel "SpriteFrames":

../../_images/spriteframes_panel.png

A la izquierda hay una lista de animaciones. Haz clic en la opción "default" y renómbrala a "walk". Luego haz clic en el botón "Nueva Animación" para crear una segunda animación llamada "up". Busca las imágenes en el panel "Sistema de Archivos", están en la carpeta art de la carpeta descomprimina anteriormente. Arrastra las dos imágenes, llamadas playerGrey_up[1/2] y playerGrey_walk[1/2] para cada animación a la sección "Cuadros de animación" que corresponde en el panel:

../../_images/spriteframes_panel2.png

Las imágenes del jugador son un poco grandes para la ventana del juego, así que tenemos que reducirlas. Haz clic en el nodo AnimatedSprite y establece la propiedad Scale en (0.5, 0.5). Puedes encontrarla en el Inspector bajo el título Node2D.

../../_images/player_scale.png

Finalmente, añade un CollisionShape2D como hijo de Player. Esto determinará el "hitbox" del jugador, o los límites de su área de colisión. Para este personaje, un nodo CapsuleShape2D sería el más indicado ya que se ajusta mejor a la imagen, así que junto a "Shape" en el Inspector, haz clic en "<null>" -> "New CapsuleShape2D". Ahora usa los dos manejadores de tamaño y redimensiona la forma de colisión para cubrir el sprite:

../../_images/player_coll_shape.png

Cuando hayas terminado, tu escena Player debería verse así:

../../_images/player_scene_nodes.png

Estate seguro de salvar la escena de nuevo después de estos cambios.

Movimiento del jugador

Ahora necesitamos añadir alguna funcionalidad que no podemos obtener de un nodo integrado, así que añadiremos un script. Haz clic en el nodo Player y en el botón "Añadir Script":

../../_images/add_script_button.png

En la ventana de configuración del script, puedes dejar los ajustes por defecto. Simplemente haz clic en "Crear":

Nota

Si quieres crear un script C# u otro lenguaje, selecciona el lenguaje en el menú desplegable "Lenguaje " antes de pulsar crear.

../../_images/attach_node_window.png

Nota

Si esta es la primera vez que te enfrentas con GDScript, por favor, lee Scripting antes de continuar.

Comienza declarando las variables miembro que este objeto necesitará:

extends Area2D

export var speed = 400  # How fast the player will move (pixels/sec).
var screen_size  # Size of the game window.
public class Player : Area2D
{
    [Export]
    public int Speed = 400; // How fast the player will move (pixels/sec).

    private Vector2 _screenSize; // Size of the game window.
}

Utilizar la palabra clave export en la primera variable speed nos permite asignar su valor desde el Inspector. Esto puede ser útil para valores que quieres ajustar como ajustas las propiedades de los nodos comunes. Haz clic en el nodo Player y verás que en la sección "Script Variables" del Inspector aparece la nueva propiedad. Recuerda que si cambias el valor aquí, sobrescribirá el valor asignado en el script al ejecutar el juego.

Advertencia

Si estás usando C#, necesitas (re)construir los archivos assembly del proyecto cada vez que quieras tener visibles las variables exportadas o las señales. Esta construcción puede ser lanzada manualmente pulsando en la palabra "Mono" en la parte inferior de la ventana de edición para revelar el Panel Mono, y luego haciendo clic en el botón "Construir Proyecto".

../../_images/export_variable.png

La función _ready() se llama cuando un nodo entra en la escena, este es un buen momento para averiguar el tamaño de la ventana de juego:

func _ready():
    screen_size = get_viewport_rect().size
public override void _Ready()
{
    _screenSize = GetViewport().Size;
}

Ahora podemos utilizar la función _process() para definir lo que hará el jugador. _process() se llama en cada frame, así que la usaremos para actualizar elementos del juego que esperamos que cambien a menudo. Para el jugador haremos lo siguiente:

  • Comprobar entradas.
  • Moverse en la dirección dada.
  • Reproducir la animación apropiada.

Primero, necesitamos comprobar entradas - ¿está el jugador presionando una tecla? Para este juego, tenemos 4 entradas de dirección para comprobar. Las acciones de entrada (Input actions) se definen en Ajustes del Proyecto, dentro de la pestaña Mapas de Entrada. Puedes definir eventos personalizados y asignarlos a diferentes claves, eventos del ratón u otras entradas. Para esta demo, usaremos los eventos por defecto que se asignan a las flechas del teclado.

Puedes detectar si una tecla se está presionando usando Input.is_action_pressed(), lo que devuelve true si está presionada o false en caso contrario.

func _process(delta):
    var velocity = Vector2()  # The player's movement vector.
    if Input.is_action_pressed("ui_right"):
        velocity.x += 1
    if Input.is_action_pressed("ui_left"):
        velocity.x -= 1
    if Input.is_action_pressed("ui_down"):
        velocity.y += 1
    if Input.is_action_pressed("ui_up"):
        velocity.y -= 1
    if velocity.length() > 0:
        velocity = velocity.normalized() * speed
        $AnimatedSprite.play()
    else:
        $AnimatedSprite.stop()
public override void _Process(float delta)
{
    var velocity = new Vector2(); // The player's movement vector.

    if (Input.IsActionPressed("ui_right"))
    {
        velocity.x += 1;
    }

    if (Input.IsActionPressed("ui_left"))
    {
        velocity.x -= 1;
    }

    if (Input.IsActionPressed("ui_down"))
    {
        velocity.y += 1;
    }

    if (Input.IsActionPressed("ui_up"))
    {
        velocity.y -= 1;
    }

    var animatedSprite = GetNode<AnimatedSprite>("AnimatedSprite");

    if (velocity.Length() > 0)
    {
        velocity = velocity.Normalized() * Speed;
        animatedSprite.Play();
    }
    else
    {
        animatedSprite.Stop();
    }
}

Comenzaremos colocando velocity en (0,0) - por defecto, el jugador no se debería estar moviendo. Luego revisamos cada entrada y sumamos o restamos de velocity para obtener la dirección. Por ejemplo, si mantienes right (derecha) y down (abajo) al mismo tiempo, el resultado del vector velocity será (1,1). En este caso, como estamos agregando un movimiento horizontal y vertical, el jugador podrá moverse más rápido que si solo se estuviese moviendo horizontalmente.

Podemos prevenir eso normalizando velocity, lo que significa que su longitud será 1, y multiplicando por la velocidad total deseada. Esto hará que el movimiento diagonal no sea más rápido.

Truco

Si no has usado antes cálculo vectorial, o necesitas un repaso, puedes ver una explicación de la utilización de vectores en Godot en Matemáticas vectoriales. Es bueno saberlo pero no será necesario para el resto de este tutorial.

También revisaremos si el jugador (player) se está moviendo llamar a play() (comenzar) o stop() (detener) en el AnimatedSprite.

$ es una abreviatura de``get_node()``. En el código anterior, $AnimatedSprite.play() es lo mismo que get_node("AnimatedSprite").play().

Truco

En GDScript, $ retorna el nodo acorde a la ruta relativa al nodo actual, o null si no se encuentra el nodo. Como AnimatedSprite es hijo del nodo actual, podemos utilizar $AnimatedSprite.

Ahora que tenemos la dirección del movimiento, podemos actualizar la posición de Player. También podemos usar clamp() para prevenir que abandone la pantalla. Aplicar clamp quiere decir que vamos a restringir un valor a un determinado rango. Agrega lo siguiente al final de la función _process (asegúrate de que no esté indentada dentro del else):

position += velocity * delta
position.x = clamp(position.x, 0, screen_size.x)
position.y = clamp(position.y, 0, screen_size.y)
Position += velocity * delta;
Position = new Vector2(
    x: Mathf.Clamp(Position.x, 0, _screenSize.x),
    y: Mathf.Clamp(Position.y, 0, _screenSize.y)
);

Truco

El parámetro delta en la función _process() se refiere al frame length -la cantidad de tiempo que le tomo al frame anterior para completarse. Usando este valor aseguras que tu movimiento sera consistente incluso si el frame rate cambia .

Haz click en "Reproducir Escena" (F6) y confirma que puedes mover el jugador alrededor de la escena en todas direcciones.

Advertencia

Si tu obtienes un error en el panel "Debugger" que dice

`` Intento de llamar a la función 'play' en la base 'null instance' en una instancia nula ''

esto puede significar que se ha escrito mal el nombre del nodo AnimatedSprite. Los nombres de nodo son sensible a mayúsculas y $NodeName debe coincidir con el nombre que ves en el árbol de escenas.

Seleccionar animaciones

Ahora que Player se puede mover, necesitamos cambiar la animación que AnimatedSprite ejecutará basándose en la dirección. Tenemos una animación "walk" (caminar), que mostrará a Player caminando hacia la derecha. Esta animación será volteada (flip) horizontalmente usando la propiedad flip_h para movimiento hacia la izquierda. También tenemos la animación "up" (arriba) que usará flip_v para movimiento hacia abajo. Coloquemos este código al final de nuestra función _process():

if velocity.x != 0:
    $AnimatedSprite.animation = "walk"
    $AnimatedSprite.flip_v = false
    # See the note below about boolean assignment
    $AnimatedSprite.flip_h = velocity.x < 0
elif velocity.y != 0:
    $AnimatedSprite.animation = "up"
    $AnimatedSprite.flip_v = velocity.y > 0
if (velocity.x != 0)
{
    animatedSprite.Animation = "walk";
    animatedSprite.FlipV = false;
    // See the note below about boolean assignment
    animatedSprite.FlipH = velocity.x < 0;
}
else if (velocity.y != 0)
{
    animatedSprite.Animation = "up";
    animatedSprite.FlipV = velocity.y > 0;
}

Nota

Las asignaciones booleanas en el código de arriba son una abreviatura para los programadores. Como estamos haciendo una comparación (booleana) y al mismo tiempo asignando un valor booleano, podemos hacer las dos cosas a la vez. Considere este código versus la asignación booleana abreviada de arriba:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false
if (velocity.x < 0)
{
    animatedSprite.FlipH = true;
}
else
{
    animatedSprite.FlipH = false;
}

Reproduce la escena de nuevo y revisa si las animaciones son correctas en cada una de las direcciones.

Truco

Un error común aquí es escribir los nombres de las animaciones mal. Los nombres de las animaciones en el SpriteFrames panel deberán coincidir con lo que tu escribes en tu código. si tu llamas la animación "Caminar", tu también deberás usar la mayúscula "C" en el código.

Cuando estés seguro de que el movimiento funciona correctamente, agrega esta línea a _ready() para que el jugador esté oculto cuando comience el juego:

hide()
Hide();

Preparacion para las colisiones

Queremos detectar cuando un enemigo alcanza a Player, pero no hemos creado ningún enemigo todavía. Eso está bien, porque vamos a utilizar la funcionalidad de señales (signal) de Godot para hacer que funcione.

Añade lo siguiente en la parte superior del script, después de extends Area2d:

signal hit
// Don't forget to rebuild the project so the editor knows about the new signal.

[Signal]
public delegate void Hit();

Esto define una señal personalizada llamada "hit" que nuestro player emitirá (enviará) cuando colisione con un enemigo. Usaremos Area2D para detectar la colisión. Selecciona el nodo Player y haz clic en la pestaña "Nodos" junto a la pestaña Inspector para ver la lista de señales que puede emitir el player:

../../_images/player_signals.png

Observa que nuestra señal personalizada de "hit" también está ahí. Como nuestros enemigos van a ser nodos RigidBody2D, necesitamos la señal body_entered( Object body ). Esta señal se emitirá cuando un cuerpo entre en contacto con el player. Haz clic en "Conectar..." y luego en "Conectar" de nuevo en la ventana " Conectar señal ". No necesitamos cambiar ninguna de estas configuraciones. Godot entonces creará automáticamente una función en el script de Player.

../../_images/player_signal_connection.png

Nota el icono verde indicando que una señal es conectada a esta función. Agrega este código a la función:

func _on_Player_body_entered(body):
    hide()  # Player disappears after being hit.
    emit_signal("hit")
    $CollisionShape2D.set_deferred("disabled", true)
public void OnPlayerBodyEntered(PhysicsBody2D body)
{
    Hide(); // Player disappears after being hit.
    EmitSignal("Hit");
    GetNode<CollisionShape2D>("CollisionShape2D").SetDeferred("disabled", true);
}

Cada vez que un enemigo golpea al jugador, la señal será emitida. Necesitamos deshabilitar la colisión del jugador para que no activemos la señal de hit más de una vez.

Nota

Desactivar la forma de colisión del nodo Area puede causar un error si ocurre en medio del procesado de colisiones del motor. El uso de set_deferred() nos permite hacer que Godot espere para desactivar la forma hasta que sea seguro hacerlo.

Lo último será agregar una función que llamaremos para reiniciarlo cuando comenzamos un juego nuevo.

func start(pos):
    position = pos
    show()
    $CollisionShape2D.disabled = false
public void Start(Vector2 pos)
{
    Position = pos;
    Show();
    GetNode<CollisionShape2D>("CollisionShape2D").Disabled = false;
}

Escena del enemigo

Es momento de hacer los enemigos que nuestro jugador tendrá que evitar. Su comportamiento no será muy complejo: los enemigos aparecerán al azar en los bordes de la pantalla y se moverán en una dirección al azar en línea recta.

Crearemos una escena Mob, la que podremos instanciar para crear cualquier número independiente de enemigos en el juego.

Nota

Mira Instanciar para aprender más sobre instanciación.

Configuración del Nodo

Haz clic en Escena -> Nueva escena y agrega los siguientes nodos:

No olvides marcar los hijos para que no se puedan seleccionar, como hiciste con la escena Player.

En las propiedades de RigidBody2D, ajustar Gravity Scale a 0, así los enemigos no caerán hacia abajo. Además, dentro de la sección PhysicsBody2D, haz clic en la propiedad Mask y desmarca el primer cuadro, para asegurarse que los enemigos no colisionen entre sí.

../../_images/set_collision_mask.png

Configura el AnimatedSprite como hiciste con el jugador. Esta vez, tenemos 3 animaciones: fly, swim, y walk. Hay dos imágenes para cada animación en la carpeta art.

Ajusta la "Speed (FPS)" a 3 para todas las animaciones.

../../_images/mob_animations.gif

Marca la propiedad Playing como "Activado" en el inspector.

Nosotros seleccionamos una de estas animaciones al azar así los mobs tendrán algo de variedad.

Como las imágenes del jugador, la de los enemigos deberán escalarse. Ajusta la propiedad Scale de AnimatedSprite a (0.75, 0.75).

Igual que en la escena Player, añade un CapsuleShape2D para las colisiones. Para alinear la forma de colisión con la imagen, deberás ajustar la propiedad Rotation Degrees a 90 (en la sección "Transform" del Inspector).

Guarda la escena.

Script del enemigo

Añade un script al nodo Mob y luego añade las siguientes variables miembro:

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
public class Mob : RigidBody2D
{
    // Don't forget to rebuild the project so the editor knows about the new export variables.

    [Export]
    public int MinSpeed = 150; // Minimum speed range.

    [Export]
    public int MaxSpeed = 250; // Maximum speed range.

}

Cuando creamos un "mob", tomaremos un número al azar entre min_speed y max_speed para decidir cuán rápido se debe mover cada uno (sería aburrido si todos se moviesen a la misma velocidad).

Ahora veamos el resto del script. En _ready() seleccionaremos al azar una de las tres animaciones:

func _ready():
    var mob_types = $AnimatedSprite.frames.get_animation_names()
    $AnimatedSprite.animation = mob_types[randi() % mob_types.size()]
// C# doesn't implement GDScript's random methods, so we use 'System.Random' as an alternative.
static private Random _random = new Random();

public override void _Ready()
{
    var animSprite = GetNode<AnimatedSprite>("AnimatedSprite");
    var mobTypes = animSprite.Frames.GetAnimationNames();
    animSprite.Animation = mobTypes[_random.Next(0, mobTypes.Length)];
}

Primero, obtenemos la lista de nombres de animación de la propiedad frames del AnimatedSprite. Esto devuelve un Arreglo que contiene los tres nombres de las animaciones: ["walk", "swim", "fly"] (caminar, nadar y volar respectivamente).

Luego debemos tomar un número al azar entre 0 y 2 para seleccionar uno de los nombres en la lista (los índices de los arreglos comienzan en 0). randi() % n selecciona un entero al azar entre 0 y n-1.

Nota

Tienes que usar randomize() si quieres que la secuencia de números al "azar" sea diferente cada vez que se ejecute la escena. Usaremos randomize() en la escena Main, así que no lo necesitaremos aquí.

La última parte es hacer que los enemigos se borren a sí mismos cuando abandonan la pantalla. Conecta la señal screen_exited() del nodo VisibilityNotifier2D y agrega este código:

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()
public void OnVisibilityNotifier2DScreenExited()
{
    QueueFree();
}

Esto completa la escena Mob.

Escena principal

Ahora es momento de juntar todo. Crea una nueva escena y agrega un Node llamado Main. Haz clic en el botón "Instanciar" y selecciona la escena grabada Player.tscn.

../../_images/instance_scene.png

Ahora agrega los siguientes nodos como hijos de Main, y nómbralos como se muestra (los valores son en segundos):

  • Timer (llamado MobTimer) - para controlar cuán seguido aparecerán los enemigos
  • Timer (llamado ScoreTimer) - para incrementar el puntaje cada segundo
  • Timer (llamado StartTimer) - para crear una retardo antes de comenzar
  • Position2D (llamado StartPosition) - para indicar la posición inicial del jugador

Ajustar la propiedad Wait Time (tiempo de espera) de cada nodo Timer como se indica:

  • MobTimer: 0.5
  • ScoreTimer: 1
  • StartTimer: 2

Además, marca la propiedad One Shot (disparo único) como "Activado" y ajusta la propiedad Position del nodo StartPosition en (250,450).

Agregando enemigos

El nodo Main generará los nuevos enemigos, y queremos que aparezcan en una ubicación al azar en el borde de la pantalla. Agregue un nodo Path2D llamado MobPath como hijo de Main. Cuando seleccione Path2D, verá que aparecen nuevos botones en la parte de arriba del editor:

../../_images/path2d_buttons.png

Selecciona el botón del medio ("Añadir Punto") y dibuja el camino haciendo clic para agregar los puntos en las esquinas como se muestra. Para tener los puntos ajustados a la rejilla, asegúrate de tener marcado "Ajustar a cuadrícula". Esta opción se encuentra a la izquierda del botón "Bloquear", que aparece como un imán al lado de las líneas de intersección.

../../_images/grid_snap_button.png

Importante

Dibuja la ruta en el sentido de las agujas del reloj ¡o tus enemigos apuntarán hacia afuera en lugar de hacia adentro!

../../_images/draw_path2d.gif

Después de colocar el punto 4, haz clic en el botón "Cerrar Curva" y la curva se completará.

Ahora que el camino está definido, agrega un nodo PathFollow2D como hijo de MobPath y nómbralo MobSpawnLocation. Este nodo rotará automáticamente y seguirá el camino mientras se mueve, así podemos usarlo para seleccionar una posición al azar y una dirección a lo largo del camino.

Tu escena debería verse así:

../../_images/main_scene_nodes.png

Script Principal

Agrega un script a Main. Al principio del script usaremos export (PackedScene) para permitirnos elegir la escena de enemigos que queremos instanciar.

extends Node

export (PackedScene) var Mob
var score

func _ready():
    randomize()
public class Main : Node
{
    // Don't forget to rebuild the project so the editor knows about the new export variable.

    [Export]
    public PackedScene Mob;

    private int _score;

    // We use 'System.Random' as an alternative to GDScript's random methods.
    private Random _random = new Random();

    public override void _Ready()
    {
    }

    // We'll use this later because C# doesn't support GDScript's randi().
    private float RandRange(float min, float max)
    {
        return (float)_random.NextDouble() * (max - min) + min;
    }
}

Haz clic en el nodo Principal y verás la propiedad Mob en el Inspector bajo "Script Variables".

Puedes asignar el valor de esta propiedad de dos maneras:

  • Arrastra Mob.tscn``del panel "Sistema de Archivos" y suéltalo en la propiedad ``Mob.
  • Haga clic en la flecha hacia abajo junto a "[empty]" y elija "Load". Selecciona Mob.tscn.

Next, select the Player node in the Scene dock, and access the Node dock on the sidebar. Make sure to have the Signals tab selected in the Node dock.

You should see a list of the signals for the Player node. Find and double-click the hit signal in the list (or right-click it and select "Connect..."). This will open the signal connection dialog. We want to make a new function named game_over, which will handle what needs to happen when a game ends. Type "game_over" in the "Receiver Method" box at the bottom of the signal connection dialog and click "Connect". Add the following code to the new function, as well as a new_game function that will set everything up for a new game:

func game_over():
    $ScoreTimer.stop()
    $MobTimer.stop()

func new_game():
    score = 0
    $Player.start($StartPosition.position)
    $StartTimer.start()
public void GameOver()
{
    GetNode<Timer>("MobTimer").Stop();
    GetNode<Timer>("ScoreTimer").Stop();
}

public void NewGame()
{
    _score = 0;

    var player = GetNode<Player>("Player");
    var startPosition = GetNode<Position2D>("StartPosition");
    player.Start(startPosition.Position);

    GetNode<Timer>("StartTimer").Start();
}

Ahora conecta la señal timeout() de cada uno de los nodos Timer (StartTimer, ScoreTimer y MobTimer) al script de Main. StartTimer iniciará los otros dos temporizadores. ScoreTimer incrementará la puntuación en 1.

func _on_StartTimer_timeout():
    $MobTimer.start()
    $ScoreTimer.start()

func _on_ScoreTimer_timeout():
    score += 1
public void OnStartTimerTimeout()
{
    GetNode<Timer>("MobTimer").Start();
    GetNode<Timer>("ScoreTimer").Start();
}

public void OnScoreTimerTimeout()
{
    _score++;
}

En _on_MobTimer_timeout(), crearemos una instancia del enemigo, tomaremos una ubicación aleatoria a lo largo del Path2D, y luego pondermos al enemigo en movimiento. El nodo PathFollow2D rotará automáticamente mientras recorre el camino, así que usaremos eso para seleccionar la dirección y la posición del enemigo.

Observa que añadiremos la nueva instancia a la escena usando add_child().

func _on_MobTimer_timeout():
    # Choose a random location on Path2D.
    $MobPath/MobSpawnLocation.offset = randi()
    # Create a Mob instance and add it to the scene.
    var mob = Mob.instance()
    add_child(mob)
    # Set the mob's direction perpendicular to the path direction.
    var direction = $MobPath/MobSpawnLocation.rotation + PI / 2
    # Set the mob's position to a random location.
    mob.position = $MobPath/MobSpawnLocation.position
    # Add some randomness to the direction.
    direction += rand_range(-PI / 4, PI / 4)
    mob.rotation = direction
    # Set the velocity (speed & direction).
    mob.linear_velocity = Vector2(rand_range(mob.min_speed, mob.max_speed), 0)
    mob.linear_velocity = mob.linear_velocity.rotated(direction)
public void OnMobTimerTimeout()
{
    // Choose a random location on Path2D.
    var mobSpawnLocation = GetNode<PathFollow2D>("MobPath/MobSpawnLocation");
    mobSpawnLocation.Offset = _random.Next();

    // Create a Mob instance and add it to the scene.
    var mobInstance = (RigidBody2D)Mob.Instance();
    AddChild(mobInstance);

    // Set the mob's direction perpendicular to the path direction.
    float direction = mobSpawnLocation.Rotation + Mathf.Pi / 2;

    // Set the mob's position to a random location.
    mobInstance.Position = mobSpawnLocation.Position;

    // Add some randomness to the direction.
    direction += RandRange(-Mathf.Pi / 4, Mathf.Pi / 4);
    mobInstance.Rotation = direction;

    // Choose the velocity.
    mobInstance.LinearVelocity = new Vector2(RandRange(150f, 250f), 0).Rotated(direction);
}

Importante

¿Por qué PI? En funciones que requieren ángulos, GDScript usa radianes, no grados. Si prefieres utilizar grados, deberás utilizar deg2rad() y rad2deg() para convertir entre ambas.

Probando la escena

Probemos la escena para asegurarnos de que todo funcione. Agregue esto a `` _ready () ``:

func _ready():
    randomize()
    new_game()
    public override void _Ready()
    {
        NewGame();
    }
}

También asignemos Principal como nuestra Escena Principal, la que se ejecuta automáticamente cuando se lanza el juego. Presiona el botón "Play" y selecciona Main.tscn cuando se te pida.

Deberías poder mover al jugador alrededor, ver a los mobs creándose, y ver al jugador desaparecer cuando es golpeado por un mob.

Cuando estés seguro de que todo funciona, quita la llamada a new_game() de ready().

HUD

La pieza final de nuestro juego es una UI (interfaz de usuario): una interfaz para mostrar cosas como la puntuación, el mensaje de "juego terminado" y un botón de reinicio. Crea una nueva escena, agrega un nodo CanvasLayer llamado HUD. "HUD" viene de "heads-up display" y es la serie de elementos informativos que aparecen encima de la vista de juego.

El nodo CanvasLayer nos permitirá colocar elementos de nuestra UI en una capa por encima del juego, así la información que mostrará no estará cubierta por ningún elemento como el jugador o los enemigos.

El HUD necesitará mostrar la siguiente información:

  • Puntuación, cambiado por ScoreTimer.
  • Un mensaje como "Game Over" o "Get Ready!"
  • Un botón "Start" para comenzar el juego.

El nodo básico para elementos de UI es Control. Para crear nuestra UI, usaremos dos tipos de nodos Control: Label y Button.

Cree los siguientes hijos del nodo HUD:

  • Label llamado ScoreLabel.
  • Label llamado Message.
  • Button llamado StartButton.
  • Timer llamado MessageTimer.

Haz clic en ScoreLabel y escribe un número en el campo Text del inspector. La fuente por defecto para los nodos Control es pequeña y no escala bien. En los recursos del juego se incluye una fuente llamada "Xolonium-Regular.ttf". Para usar esta fuente, haz lo siguiente:

  1. Dentro de "Custom Fonts", seleccionar "New DynamicFont"
../../_images/custom_font1.png
  1. Haz clic en la "DynamicFont" agregada y, dentro de "Font Data", elige "Load" y selecciona el archivo "Xolonium-Regular.ttf". Deberás también cambiar el Size (tamaño) de la fuente. Un valor de 64 funcionará bien.
../../_images/custom_font2.png

Una vez que haz hecho esto en ScoreLabel, puedes hacer clic en la flecha junto a la propiedad DynamicFont y seleccionar "Copiar", luego "Pegar" en el mismo lugar en los otros dos nodos Control.

Nota

Anclas y Márgenes: Nodos Control tienen posición y tamaño, pero también tienen anclas (anchor) y márgenes (margin). Anclas definen el origin - El punto de referencia para los bordes del nodo. Los márgenes se actualizan automaticamente cuando cambias el tamaño de un nodo Control. Representan la distancia del borde al ancla del nodo Control. Para más detalles, ver Diseño de interfaces con nodos Control.

Organiza los nodos como se muestra a continuación. Haz clic en el botón "Layout" para establecer la disposición de un nodo de Control:

../../_images/ui_anchor.png

Puedes arrastrar y ubicarlos manualmente o, para un modo más preciso, usa la siguiente configuración:

ScoreLabel

  • Layout: "Top Wide"
  • Text : 0
  • Align: "Center"

Message

  • Disposición : "HCenter Wide"
  • Text : Dodge the Creeps!
  • Align: "Center"
  • Envoltura Automatica : "Encendida"

StartButton

  • Text : Start
  • Disposición : "Center Bottom"
  • Margin:
    • Top: -200
    • Bottom: -100

En el MessageTimer, ajusta el Wait Time en 2 y la propiedad One Shot en "Activado".

Ahora agrega este script a HUD:

extends CanvasLayer

signal start_game
public class HUD : CanvasLayer
{
    // Don't forget to rebuild the project so the editor knows about the new signal.

    [Signal]
    public delegate void StartGame();
}

La señal start_game dirá al nodo Main que se ha presionado un botón.

func show_message(text):
    $Message.text = text
    $Message.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var message = GetNode<Label>("Message");
    message.Text = text;
    message.Show();

    GetNode<Timer>("MessageTimer").Start();
}

Esta función se llamará cuando queramos mostrar un mensaje temporalmente, como "Get Ready".

func show_game_over():
    show_message("Game Over")
    # Wait until the MessageTimer has counted down.
    yield($MessageTimer, "timeout")

    $Message.text = "Dodge the\nCreeps!"
    $Message.show()
    # Make a one-shot timer and wait for it to finish.
    yield(get_tree().create_timer(1), "timeout")
    $StartButton.show()
async public void ShowGameOver()
{
    ShowMessage("Game Over");

    var messageTimer = GetNode<Timer>("MessageTimer");
    await ToSignal(messageTimer, "timeout");

    var message = GetNode<Label>("Message");
    message.Text = "Dodge the\nCreeps!";
    message.Show();

    await ToSignal(GetTree().CreateTimer(1), "timeout");
    GetNode<Button>("StartButton").Show();
}

Esta función se llamará cuando el jugador pierde. Mostrará "Game Over" durante 2 segundos, luego volverá a la pantalla de título y revelará el botón "Start".

Nota

Cuando necesite hacer una pausa por un breve tiempo, una alternativa al uso de un nodo temporizador es usar la función create_timer() del árbol de escena. Esto puede ser muy útil para retrasar, como en el código anterior, donde queremos esperar un poco de tiempo antes de mostrar el botón "Start".

func update_score(score):
    $ScoreLabel.text = str(score)
public void UpdateScore(int score)
{
    GetNode<Label>("ScoreLabel").Text = score.ToString();
}

Esta función será llamada por Main cada vez que el puntaje cambie.

Conecta la señal timeout() del MessageTimer y la señal pressed() de StartButton y agrega el siguiente código a las nuevas funciones:

func _on_StartButton_pressed():
    $StartButton.hide()
    emit_signal("start_game")

func _on_MessageTimer_timeout():
    $Message.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

public void OnMessageTimerTimeout()
{
    GetNode<Label>("Message").Hide();
}

Conectando HUD a Main

Ahora que terminamos la creación de la escena HUD, vuelve a Main. Instancia la escena HUD en Main como se hizo con la escena Player. El árbol completo debería verse así, así que asegúrate de que no falta nada:

../../_images/completed_main_scene.png

Ahora conectaremos la funcionalidad de HUD a nuestro script Main. Esto requiere unas pocas adiciones a la escena Main:

En la pestaña Nodo, conecte la señal `` start_game '' del HUD a la función `` new_game () `` del nodo Principal escribiendo "new_game" en el "Método del receptor" en la ventana "Conectar una señal". Verifique que el icono de conexión verde ahora aparezca junto a `` func new_game () `` en el script.

En``new_game()``, actualiza la puntuación y muestra el mensaje "Get Ready":

$HUD.update_score(score)
$HUD.show_message("Get Ready")
var hud = GetNode<HUD>("HUD");
hud.UpdateScore(_score);
hud.ShowMessage("Get Ready!");

En game_over() necesitaremos llamar la función correspondiente en HUD:

$HUD.show_game_over()
GetNode<HUD>("HUD").ShowGameOver();

Finalmente, agrega _on_ScoreTimer_timeout() para mantener la interfaz en sincronía al cambiar la puntuación:

$HUD.update_score(score)
GetNode<HUD>("HUD").UpdateScore(_score);

¡Ahora estás listo para jugar! Haz clic en el botón "Reproducir el proyecto". Se te pedirá que selecciones una escena principal, así que elige Main.tscn.

Removiendo los viejos "creeps"

Si juegas hasta el "Game Over" y comienzas un juego nuevo, los "creeps" del juego previo estarán todavía en pantalla. Sería mejor si todos desaparecen al comienzo del juego nuevo. Sólo necesitamos decirle a todos los enemigos que se remuevan solos. Podemos hacer esto con la característica de "grupos".

En la escena Mob, selecciona el nodo raíz y haz clic en la pestaña "Node" junto al Inspector (el mismo lugar donde encuentras las señales del nodo). Al lado de "Señales", haz clic en "Grupos" y puedes escribir un nuevo nombre de grupo y hacer clic en "Añadir".

../../_images/group_tab.png

Ahora todos los enemigos estarán en el grupo "mobs". Podremos entonces agregar la siguiente línea a la función game_over() en Main:

get_tree().call_group("mobs", "queue_free")
GetTree().CallGroup("mobs", "queue_free");

La función call_group() llama a la función nombrada en cada nodo de un grupo - en este caso le estamos diciendo a cada grupo que se elimine a sí mismo.

Finalizando

Ahora que hemos completado toda la funcionalidad de nuestro juego. Debajo están los pasos restantes para agregar más "jugo" y mejorar la experiencia de juego. Eres libre de expandir el juego con sus propias ideas.

Imagen de fondo

El gris por defecto no es muy atractivo, así que cambiemos el color. Un modo de hacer esto es usar un nodo ColorRect. Lo haremos el primer nodo dentro de Main así aparecerá detrás de los otros nodos. ColorRect tiene una sola propiedad: Color. Escoge el color que quieras y arrastra el borde del ColorRect para que cubra la pantalla.

También puedes agregar una imagen de fondo, si tienes una, utilizando un nodo TextureRect en su lugar.

Efectos de sonido

La música y el sonido pueden ser el modo más efectivo de agregar atractivo a la experiencia de juego. En la carpeta de recursos hay dos archivos de sonido: "House In a Forest Loop.ogg" para música de fondo y "gameover.wav" para cuando el jugador pierde.

Agrega dos nodos AudioStreamPlayer hijos de Main. Nombre uno de ellos Music y el otro DeathSound. En cada uno, haz clic en la propiedad Stream, selecciona "Load" y escoge el archivo de audio correspondiente.

Para reproducir la música, agregua $Music.play() en la función new_game() y $Music.stop() en game_over().

Finalmente, agregua $DeathSound.play() en la función game_over().

Atajo del teclado

Dado que el juego se juega con los controles del teclado, sería conveniente si también pudiéramos iniciar el juego presionando una tecla. Una forma de hacerlo es usando la propiedad "Shortcut" del nodo Button.

En la escena HUD, selecciona el StartButton y encuentra su propiedad Shortcut* en el Inspector. Selecciona "New Shortcut" y haz clic en la opción "Shortcut". Aparecerá una segunda propiedad Shortcut. Selecciona "New InputEventAction" y haz clic en el nuevo "InputEvent". Finalmente, en la propiedad Action, escribe el nombre ui_select. Este es el evento de entrada predeterminado asociado a la barra espaciadora.

../../_images/start_button_shortcut.png

Ahora cuando aparezca el botón de inicio, puedes hacer clic en él o presionar la barra espaciadora para iniciar el juego.

Archivos del Proyecto

Encontrarás una versión completada de este proyecto en las siguientes direcciones: