03 Experimentos con Nodos - hektorprofe/godot-descubre GitHub Wiki

Escena con nombre SandboxExperiments.tscn y los scripts en un directorio scripts/SandboxExperiments.

Vamos escondiendo los nodos que no usemos y yastá.

18. Ilusión de Movimiento Básico

Aplicar variables para crear movimiento básico de izquierda a derecha.

  • Después de este inciso aprendiendo GDScript podemos continuar con nuestros experimentos interactivos.
  • En esta experimento vamos a aprender lo más básico, la forma en que se genera la ilusión de movimiento de los videojuegos.
  • Volviendo a nuestra escena Main vamos a desvincular el script actual pulsando el pergamino con la X roja.
  • También vamos a reiniciar las propiedades de transformación del icono que dejamos modificadas.
  • Dejaremos la posición del Nodo Icono, que recordemos es un Sprite2D, en la coordenada (200, 150).
  • Bien, ahora ¿recordáis la función llamada _process que hemos estado utilizando sin saber muy bien qué es?
  • Vamos a crear un nuevo script para el nodo Icon, podemos llamarlo res://scripts/Desplazamiento.gd.
  • Y vamos a fijarnos en la documentación de esa función _process:
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
    pass
  • Lo que nos dice es que esta función se llama en cada fotograma y delta es una variable que contiene el tiempo que ha pasada entre el fotograma actual y el anterior. Vamos a imprimir por pantalla el valor delta en cada fotograma:
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
    print(delta)
  • Nos aparecerá una ristra interminable de números en la terminal, son números decimales muy pequeños:
0.00833333333333
0.00833333333333
0.00833333333333
0.00833333333333
  • Esto estimados son los segundos que pasan entre cada fotograma. ¿Qué creéis que obtendremos si dividimos 1 segundo entre el tiempo que tarda en ejecutarse cada fotograma?
func _process(delta: float) -> void:
    print(1/delta)
  • Pues nada más ni nada menos que la cantidad media de fotogramas a los que se ejecuta el videojuego:
120
120
120
120
  • ¡Como veis se ejecuta muchas veces! La cantidad depende de vuestro monitor y posiblemente de la sincronización vertical, por ejemplo en mi caso obtengo unos 120 FPS (fotogramas por segundo).

  • Si supiéramos un poco más os diría de crear un texto para visualizar los fotogramas en todo momento, pero como aún no sabemos tanto hagamos algo más sencillo.

  • Vamos a utilizar un método de la librería DisplayServer de Godot que nos permite modificar el título de la ventana por los FPS:

func _process(delta: float) -> void:
    var fps = 1/delta
    DisplayServer.window_set_title(fps)
  • Veréis que este código fallará, nos dice que no podemos establecer el título con un número. Pero sí que podemos transformar un número a texto, eso no lo he explicado antes porque es mejor verlo sobre la marcha.
  • Mediante la función str() podemos transformar un dato numérico a cadena de texto muy fácilmente:
func _process(delta: float) -> void:
    var fps = str(1/delta)  # cast de float a String
    DisplayServer.window_set_title(fps)
  • Continuando con el experimento, aprovechando que la función _process se ejecuta constantemente, ¿Qué ocurriría si incrementamos en 1 la posición horizontal x del sprite en cada fotograma? Hagámoslo.
  • Como estamos programando un script enlazado a un Node2D de tipo Sprite2D, éste tiene el conjunto de propiedades Transform. Si pasamos el raton por encima de la propiedad Position nos da el nombre position, ese el código para manipuarla desde el script. Vamos a imprimir la propiedad pantalla a ver que muestra:
func _process(delta: float) -> void:    
    var fps = str(1/delta)  # cast de float a String
    DisplayServer.window_set_title(fps)

    print(position)
  • Como era de esperar se nos muestra la posición x e y del sprite en la escena:
(200, 150)
(200, 150)
(200, 150)
  • Sin embargo fijaros que nos aparece entre paréntesis y separada por comas. ¿Parece un arreglo pero lo será?
  • Si presionamos la tecla Control y a la vez hacemos clic en la palabra position nos llevará a la documentación.
  • En la documentación podemos aprender que el tipo de dato no es un arreglo sino un Vector2.
  • Si presionamos en la documentación la definición de Vector2 nos lleva a otra sección donde explica este tipo.
  • Dice textualmente Estructura de 2 elementos que puede usarse para representas coordenadas 2D u otro par de valores numéricos. y un poco más abajo tenemos el apartado Propiedades donde nos explica que un Vector2 se compone de dos valores decimales float x y float y.
  • ¿Esto cuadra con las dos propiedades que conforman la posición no? Son las que vemos en el inspector.
  • Si hacemos clic en ellas en la propia documentación nos dice:
float x: El componente X del vector. También se puede acceder utilizando la posición del índice [0].
float y: El componente Y del vector. También se puede acceder utilizando la posición del índice [1].
  • Lo que nos está diciendo es que podemos manipular los componentes x e y de dos formas:
func _process(delta: float) -> void:
    prints("Acceso con punto", position.x, position.y)
    prints("Acceso con índice", position[0], position[1])
  • Sabiendo esto vamos a intentar sumar 1 en cada fotograma a la posición horizontal x:
func _process(delta: float) -> void:
    # position.x = position.x + 1 
    position.x += 1
  • Ahí lo tenemos, un efecto de movimiento de izquierda a derecha.
  • Si durante la ejecución cambiamos al modo Remoto del Tree View y seleccionamos el nodo Icon podemos consultar como se modifica el valor de las propiedades en tiempo real.
  • Cada vez que sumamos 1 estamos trasladando el sprite 1 píxel a la derecha, en mi caso como se ejecuta 120 veces en un segundo significa que el sprite se mueve a 120 píxeles por segundo en el eje horizontal.
  • Mover un nodo de esta forma tiene un problema. ¿Qué creéis que ocurriría si en vuestra máquina se ejecuta a 120 FPS pero un dispositivo de bajos recursos se ejecutase a 30 FPS?
  • Pues es óbvio, en vuestra máquina se moverá cuatro veces más rápido que en dispositivo de bajos recursos.
  • La idea es que nuestros videojuegos se ejecuten a una velocidad constante independiente de los FPS del dispositivo.
  • Para solucionarlo podemos ayudarnos de esa variable llamada delta.
  • En lugar de mover un sprite una cantidad fija de píxeles por fotograma, lo que podemos hacer es moverlo una cantidad determinada en función del tiempo. Eso lo conseguiremos multiplicando la cantidad que queremos moverlo en un segundo por la variable delta:
@export var velocidad : int = 100  # en pixeles por segundo

func _process(delta: float) -> void:
    position.x += velocidad * delta
  • Trabajar en función del tiempo y no de los fotogramas es básico al crear videojuegos y esa es la razón por la que Godot nos facilita la variable delta siempre que sea necesaria.

image

19. Interacción con el Teclado

Implementar la entrada del teclado para controlar el movimiento o acciones de un Sprite.

  • En la práctica anterior hemos visto cómo mover un Node2D incrementando su posición x en el tiempo. ¿No sería genial ser nosotros a través de las flechas del teclado quien mueve el sprite a la izquierda o la derecha? Vamos a hacerlo.
  • Como la función _process se ejecuta constantemente podemos comprobar en ella si en ese preciso instante se está presionando una tecla y en caso afirmativo, mediante una condición, ejecutar el movimiento.
  • Este concepto de detectar el estado de las teclas, del teclado e inclusi un mando recibe el nombre de captura de inputs y representan entradas de información del exterior hacia el programa.
  • Cada input tiene su propio código y existen diferentes estados para ellos. Por ejemplo, de una tecla concreta podemos comprobar si se esta presionando, si se ha presionado o se se ha dejado de presionar. Cada uno de esos estados de la tecla tiene un código de entrada distinto.
  • Podemos configurar nuestros propios inputs desde Proyecto > Configuración > Mapa de Entrada añadiendo acciones o utilizar las que tiene por defecto Godot mostrando las Acciones Integradas.
  • Por ejemplo, la entrada con la clave ui_accept será verdadera cuando se presiona la tecla Enter, Kp Enter o Space del teclado.
  • O una dirección de movimiento ui_left para identificar una dirección a la izquierda está mapeada con la flecha izquierda Left, el botón iquierdo de la cruceta del mando o el eje horizontal izquierdo del Joystick. Usar estos input es muy útil porque con un solo código podemos programar la entrada desde diferentes dispositivos.
  • En cualquier caso vamos a estar usando las acciones ui_left y ui_right, así que veamos como detectarlas:
var velocidad = 100 # en pixeles por segundo

# Detectamos si presionamos la acción derecha
if Input.is_action_pressed("ui_right"):
    position.x += velocidad * delta
  • Para programar el movimiento a la izquierda podemos hacer exactamente lo mismo, pero en lugar de sumar a x le restamos:
# Detectamos si presionamos la acción izquierda
if Input.is_action_pressed("ui_right"):
    position.x -= velocidad * delta
  • ¡Ya ya lo tenemos! Hemos creado nuestro primer controlador de movimiento, que también funcionará con un mando si tenéis uno conectado.
  • Antes de acabar quiero enseñaros un concepto nuevos que os gustará.
  • Se trata de exportar nuestras variables como propiedades en el inspector.
  • De la misma forma que podemos modificar los valores de position si exportamos la variable velocidad para dar la posibilidad de editarla desde la interfaz gráfica.
  • Para ello el primer paso es sacar la variable al bloque principal del script, encima de todas las definiciones de funciones:
extends Sprite2D

var velocidad = 100 # en pixeles por segundo
  • Una vez la tenemos ahí la exportaremos con la siguiente sintaxis:
extends Sprite2D

@export var velocidad: int = 100 # en pixeles por segundo
  • ¡Listo! Ahora podemos modificar esta variable como una propiedad del nodo usando el inspector.
  • Y si ejecutamos el programa podemos modificar el valor al vuelo, lo malo es que se quedará cambiado así que ojo al hacer experimentos.
  • Para rizar el rizo yo recomendaría añadir el código de movimiento en una función a parte para tenerlo todo mejor organizado. Refactorizamos un poquito aquí y allá y listo:
extends Sprite2D

@export var velocidad: int = 100 # en pixeles por segundo

func inputs(delta: float) -> void:
    # Detectamos si presionamos la acción derecha
    if Input.is_action_pressed("ui_right"):
        position.x += velocidad * delta

    # Detectamos si presionamos la acción izquierda
    if Input.is_action_pressed("ui_left"):
        position.x -= velocidad * delta
    
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
    DisplayServer.window_set_title(str(1/delta))
    inputs(delta)
  • Os reto a programar los movimientos arriba y abajo, buscad vosotros mismos las acciones up y down en el el Mapa de Entrada del proyecto para más información.

image

20. Detectar Clics del Mouse

Usar el input del mouse para mover un Sprite al lugar clicado.

  • En este experimento vamos a seguir explorando los inputs, concretamente los del mouse.
  • Nuestro objetivo consiste en cambiar la posición del sprite justo donde hagamos clic.
  • El primer paso es crear una acción para detectar el clic ya que ésta no se encuentra por defecto.
  • En el Mapa de Entrada del proyecto desactivamos las acciones integradas.
  • Para crear una nueva acción escribiremos un nombre como por ejemplo clic_izquierdo y le daremos a enter.
  • Una vez en la lista la seleccionaremos y presionaremos el botón +.
  • En esta subventana podemos hacer clic con el mouse para detectar automáticamente la acción a enlazar.
  • La que nos interesa es "Botón Izquierdo del Mouse" y le damos a Aceptar.
  • Para detectar este clic lo podemos hacer igual que con las teclas:
# Detectamos si presionamos el clic izquierdo
if Input.is_action_pressed("clic_izquierdo"):
    print("Clicado")
  • La función is_action_pressed comprueba contínuamente si presionamos el botón, pero podemos comprobar dos momentos más. Si acabamos de hacer clic is_action_just_pressed o si dejamos de hacer clic is_action_just_released. Estas variantes se ejecutan una sola vez en lugar de en cada fotograma y son más óptimas:
# Detectamos si presionamos el clic izquierdo
if Input.is_action_just_pressed("clic_izquierdo"):
    print("Recién clicado")
  • Nuestro próximo objetivo consiste en consultar la posición del mouse sobre el viewport. Tenemos dos funciones a nuestra disposición para consultar la posición local y la posición global del cursor:
if Input.is_action_just_pressed("clic_izquierdo"):
    print("Recién clicado")
    prints("posición local", get_local_mouse_position())
    prints("posición global", get_global_mouse_position())
  • Fijaros que la posición local (0,0) concuerda exactamente en el centro del icono, mientras que la posición global (0,0) se calcula en referencia a la esquina superior izquierda del viewport.
  • Como podéis suponer la que nos interesa es la posición global así que podemos simplemente asignarla a la posición del sprite:
if Input.is_action_just_pressed("clic_izquierdo"):
    print("Recién clicado")
    # Posicionamos el sprite en la posición global del ratón
    position = get_global_mouse_position()
  • Con esto ya lo tenemos, pero si queremos generar un efecto de arrastrar el sprite contínuamente podemos cambiar la función de Input a is_action_pressed como la teníamos antes:
if Input.is_action_pressed("clic_izquierdo"):
    # Posicionamos el sprite en la posición global del ratón
    position = get_global_mouse_position()
  • Ahora que ya sabemos mover sprites y detectar acciones desde dispositivos externos podemos pasar a la siguiente sección de Experimentos Interactivos, os espero.

21. Cambiar imágenes teclado

Mediante el teclado cambiar las imágenes de un sprite.

icon-green icon-red icon-yellow

La lista de sprites son el logo de Godot modificado de color usando modulate, si es que se puede al asignarlo.

extends Sprite

# Define una lista de rutas a las texturas que deseas usar
@export var textures : Array[Sprite2D]

var current_texture = 0

func _process(delta):
    # Cambiar a la textura anterior con la flecha izquierda
    if Input.is_action_just_pressed("ui_left"):
        current_texture -= 1
        if current_texture < 0:
            current_texture = textures.size() - 1
        update_texture()

    # Cambiar a la siguiente textura con la flecha derecha
    elif Input.is_action_just_pressed("ui_right"):
        current_texture += 1
        if current_texture >= textures.size():
            current_texture = 0
        update_texture()

func update_texture():
    texture = load(textures[current_texture])
    self.texture = texture

22. Rotación y escalado

Como funciona la rotación en grados y radianos y el escalado.

extends Sprite

# Variables para controlar la velocidad de rotación y escala
var rotation_speed_degrees = 90 # Grados por segundo
var rotation_speed_radians = PI # Radianes por segundo, PI radianes son 180 grados
var scaling_speed = 0.5 # Unidades de escala por segundo

func _ready():
    # Configuración inicial opcional
    pass

func _process(delta):
    # Ejemplo de rotación en radianes
    rotate(rotation_speed_radians * delta)
    # Si prefieres rotar en grados, puedes usar la siguiente línea en su lugar
    var degrees_to_radians = deg_to_rad(rotation_speed_degrees)
    rotate(degrees_to_radians * delta)

    # Ejemplo de escalado
    scale = scale + Vector2(scaling_speed * delta, scaling_speed * delta)

    # Para demostrar la conversión entre grados y radianes, podríamos imprimir los valores
    var angle_in_degrees = rotation_degrees
    var angle_in_radians = rotation
    print("Ángulo en grados: ", angle_in_degrees, ", Ángulo en radianes: ", angle_in_radians)

    # Nota: `rotation_degrees` y `rotation` son propiedades que obtienen o establecen la rotación del Sprite
    # en grados o radianes, respectivamente.

23. Movimiento diagonal

Descubrir el bug del movimiento diagonal y rectificar la velocidad.

Cuando implementas movimiento diagonal en un videojuego utilizando los métodos comunes de sumar las velocidades horizontal y vertical, a menudo encuentras un "bug" no intencionado donde el movimiento diagonal es más rápido que el movimiento horizontal o vertical por sí solos. Esto se debe a que al moverse diagonalmente, en realidad estás sumando los vectores de movimiento horizontal y vertical, resultando en un vector con una magnitud mayor que los vectores individuales. Para "rectificar" este aumento de velocidad y hacer que el movimiento diagonal sea consistente con el movimiento en un solo eje, necesitas normalizar el vector de velocidad y luego multiplicarlo por la velocidad deseada.

extends Sprite2D

@export var speed : int = 200

func _process(delta):
    var direction_pressed : Vector2
     
    if Input.is_action_pressed("ui_right"):
        direction_pressed.x += 1
    if Input.is_action_pressed("ui_left"):
        direction_pressed.x -= 1
    if Input.is_action_pressed("ui_up"):
        direction_pressed.y -= 1
    if Input.is_action_pressed("ui_down"):
        direction_pressed.y += 1
  
    var direccion_normalizada = direction_pressed.normalized()
    var longitud_direccion = sqrt(direccion_normalizada.x ** 2 + direccion_normalizada.y ** 2)
    print(longitud_direccion)
    
    position += direccion_normalizada * speed * delta

24. Cuerpos estáticos y rígidos

Aprender sobre los objetos físicos estáticos y rígidos, la gravedad y otros conceptos.

  • Durante las próximas lecciones vamos a aprender sobre el manejo de físicas.

  • Pero antes vamos a hacer limpieza guardando estos nodos como escenas en su propio subdirectorio SandboxInteractivo.

  • En Godot 4 o superior, los nodos de "cuerpos" StaticBody2D, RigidBody2D y CharacterBody2D son elementos que interactúan con las físicas del juego.

  • Sin embargo para que los cuerpos puedan detectar colisiones deben tener un CollisionShape2D o CollisionPolygon2D que defina su forma física.

  • Cuando dos formas físicas se intersecan en el espacio dan lugar a una colisión que se maneja automáticamente dando lugar a rebotes y bloqueos de movimiento.

  • Godot nos permite capturar y responder a estas colisiones utilizando señales como on_body_entered y on_body_exited o sobrescribiendo el método _physics_process(delta) para implementar lógica personalizada.

  • Empecemos experimentando con los cuerpos estáticos StaticBody2D que sirven para representar objetos inamovibles a los que no les afectan las fuerzas externas, como por ejemplo suelos y paredes.

  • Vamos a crear un suelo usando un nodo StaticBody2D con un Sprite2D que puede ser un PlaceHolderTexture con un color establecido en el Self Modulate y una CollisionShape2D.

  • Si ejecutamos el juego veremos que no ocurre nada, el cuerpo estático se queda quieto.

  • Por otra parte los cuerpos rígidos RigidBody2D son movidos por el sistema de simulación de físicas en función de sus propiedades especificas o en su defecto utilizando los valores de configuración del proyecto.

  • Vamos a crear una caja con RigidBody2D, un Sprite2D y CollisionShape2D.

  • Si ejecutamos el juego, como por defecto a los RigidBody2D les afecta la gravedad pues cae sobre el piso.

  • Al trabajar con simulaciones de físicas los cuerpos tienden a comportarte con en la realidad.

  • Podemos experimentar creando una pendiente y observando la diferencia entre objetos deslizándose por ella y cambiando sus masas.

  • También es interesante crear algunos cuerpos circulares en forma de bola para observar su comportamiento. Os adjunto un sprite con un icono redondo para hacer unas pruebas.

  • Es posible añadir materiales físicos para extender aún más sus comportamientos, por ejemplo para modificar su fricción friction o el rebote rebound.

  • Por último observemos como afecta cambiar de forma general la gravedad de todos los cuerpos al modificarla desde los ajustes del proyectos en el apartado Físicas > 2D.

25. Cuerpo de personaje

  • El nodo CharacterBody2D es una adición introducida en Godot 4.0 que substituye los antiguos KinematicBody2D.

  • Es una abstracción de un RigidBody2D diseñada específicamente para facilitar la creación de personajes que interactúan con el entorno de manera física pero controladas a través de scripts de código.

  • Ofrecen funcionalidades para implementar movimientos y comportamientos comunes de personajes, como caminar, saltar o colisionar con el entorno sin tener que manejar directamente las complejidades de la física.

  • Vamos a añadir un CharacterBody2D con un Sprite2D y un CollisionShape2D.

  • Por defecto a este cuerpo no le afecta nada, somos nosotros mediante código quienes debemos programar sus movimientos y colisiones.

  • Hagamos un ejemplo muy sencillo para entender cómo funciona.

  • Los cuerpos rígidos se mueven internamente procesando un vector llamado velocity. Éste no solo almacena la velocidad de movimiento, sino también la dirección. Como un CharacterBody2D es una abstracción de un RigidBody2D se mueve de la misma manera:

extends CharacterBody2D

func _ready(delta):
    velocity = Vector2(50, 0) # Derecha 50 píxel por segundo
  • Sin embargo no es suficiente con establecer el vector, también es necesario decirle que en cada ciclo de físicas Godot aplique la velocidad al cuerpo mediante la función move_and_slice():
extends CharacterBody2D

func _ready():
    velocity = Vector2(50, 0) # Derecha 50 píxel por segundo

func _physics_process(delta):
    move_and_slide()
  • Os habréis fijado que no estamos teniendo en cuenta delta para rectificar la velocidad, eso es porque ya se hace automáticamente en la función move_and_slide(), sin embargo hay una variante de esta función llamada move_and_collide() que requiere enviar el vector de velocidad que deseamos utilizar y sí requiere rectificarlo por delta:
extends CharacterBody2D

func _ready():
    velocity = Vector2(50, 0) # Derecha 50 píxel por segundo

func _physics_process(delta):
    # move_and_slide()
    move_and_collide(velocity * delta)
  • Algo que nos ofrecen ambas funciones es la posibilidad de detectar el nodo contra el que ocurre una colisión:
var collision = move_and_slide()
if collision:
    print("Colisión detectada")
  • Pero ya hablaremos más adelante de colisiones más adelante, por ahora hagamos un experimento.
  • Veamos cómo mover el CharacterBody2D hacia la posición donde hacemos clic con el ratón:
extends CharacterBody2D

@export var speed = 200

var target = position

func _input(event):
    if event.is_action_pressed("ui_click"):
        target = get_global_mouse_position()
        prints("Posición del clic", target)

func _physics_process(delta):
    # Como conseguimos la dirección a la posición de clic?
    velocity = ????
    move_and_slide()
  • Los Vector2 tienen muchos métodos útiles, uno de ellos es direction_to y devuelve el vector de dirección desde el propio vector hacia otro vector:
func _physics_process(delta):
    velocity = position.direction_to(target) * speed  # new
    move_and_slide()
  • Listo, pero fijaros que cuando llega al destino se mueve constantemente.
  • Esto es porque nunca llega al punto concreto, siempre se pasa de largo debido a la velocidad.
  • Una forma de solucionarlo es comprobar si estamos suficiente cerca del destino y detener el movimiento:
func _physics_process(delta):
    velocity = position.direction_to(target) * speed
    if position.distance_to(target) > 10:
        move_and_slide()
  • Por último otra función que tenemos disponible es look_at(), ésta permite rotar el cuerpo en dirección a un Vector2:
func _physics_process(delta):
    velocity = position.direction_to(target) * speed
    if position.distance_to(target) > 10:
        look_at(target)  # new
        move_and_slide()
  • Claro, aquí se genera un efecto raro porque el sprite rota sobre sí mismo al moverse a la izquierda, si por defecto el icono mirase a la derecha quedaría mejor. Solo tenemos que cambiar la rotación del sprite a 90º.

animation-gif

26. Template cuerpo personaje

  • En los nodos CharacterBody2D, Godot nos da la posibilidad de añadir un template de movimiento básico para un típico juego de plataformas 2D.
  • En esta práctica vamos a analizar el código de ese script para aprender más sobre el manejo de físicas:
extends CharacterBody2D

# Definir constantes para la velocidad de movimiento y la velocidad de salto.
const SPEED = 300.0
const JUMP_VELOCITY = -400.0

# Obtener la gravedad de las configuraciones del proyecto para sincronizarla con los nodos RigidBody.
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")


func _physics_process(delta):
    # Añadir gravedad si el personaje no está en el suelo.
    if not is_on_floor():
        velocity.y += gravity * delta
    # Manejar el salto. Se activa al presionar el botón asignado a "ui_accept" (por defecto, es la tecla 'Espacio' o 'Enter')    
    # y solo si el personaje está tocando el suelo.
    if Input.is_action_just_pressed("ui_accept") and is_on_floor():
        velocity.y = JUMP_VELOCITY

    # Obtener la dirección de entrada del usuario y manejar el movimiento/desaceleración.
    var direction = Input.get_axis("ui_left", "ui_right")
    if direction:
        velocity.x = direction * SPEED
    else:
        velocity.x = move_toward(velocity.x, 0, SPEED)

    # Mover el personaje y deslizar sobre el suelo. Esta función automáticamente maneja colisiones y rebotes.
    move_and_slide()

27. Áreas de colisión

Entender la diferencia entre las colisiones de cuerpos y áreas.

  • Anteriormente hemos visto cómo detectar colisiones entre cuerpos desde un CharacterBody2D.
  • ¿Pero y si queremos detectar colisiones sin influir en las físicas de los cuerpos? Pues para eso existen los nodos Area2D.
  • Una Area2D también necesita un CollisionShape2D o CollisionPolygon2D para establecer su espacio de detección.
  • Lo bueno es que pueden emitir señales como body_entered(body: Node) y body_exited(body: Node) para notificar la entrada y salida de cuerpos en su espacio.
  • Con ellas podemos disparar eventos como activar una trampa, abrir una puerta, cambiar el estado del juego o cualquier otro comportamiento sin influir en las físicas del cuerpo que interseca su espacio.
extends Area2D

func _ready():
  connect("body_entered", _on_body_entered)
  connect("body_exited", _on_body_exited)

func _on_body_entered(body: Node):
  print("El área ha colisionado con el cuerpo: ", body.name)

func _on_body_exited(body: Node):
  print("El área ha dejado de colisionar con el cuerpo: ", body.name)
  • Las áreas no sirven solo para detectar colisiones contra cuerpos sino que pueden modificar sus propiedades físicas mientras estos se encuentran en ellos.
  • Para ello podemos activar sus propiedades de Space Override a Replace y cambiar el eje de gravedad vertical a -1.
  • Cuando un cuerpo rígido entre en el área su gravedad se invertirá creando un efecto de rebotar en él:

animation2-gif

  • Si tenemos el origen del área centrado y activamos la opción Point la gravedad se calculará respecto a ese punto, cambiando el comportamiento a una especie de oscilización a su alrededor:

animation3-gif

  • Os animo a experimentar añadiendo más cuerpos a la escena y sobrescribiendo otras propiedades físicas.

28. Botón con sprites y áreas

En este experimento veremos otro uso que se le puede dar a las áreas.

  • Ahora crearemos un Area2D llamado BotonSprite:
  • Añadiremos un Sprite2D con un circulo blanco y un CollisionShape2D en su interior.
extends Area2D

enum Estados {NORMAL, HOVER, CLIC}
var estado : Estados

func _ready():
  estado = Estados.NORMAL
      
func _process(delta):
  match estado:
    Estados.NORMAL:
      modulate = Color.RED
    Estados.HOVER:
      modulate = Color.ORANGE
    Estados.CLIC:
      modulate = Color.GREEN

func _on_mouse_entered():
  estado = Estados.HOVER
  
func _on_mouse_exited():
  estado = Estados.NORMAL

func _on_input_event(viewport, event, shape_idx):
    if event.is_action_pressed("ui_click"):
        estado = Estados.CLIC
    if event.is_action_released("ui_click"):
        estado = Estados.HOVER

29. Cámaras de seguimiento

Configurar una cámara que siga al personaje y examinar sus propiedades.

Hasta ahora hemos trabajado siempre dentro del viewport por defecto, pero en muchas ocasiones nuestros juegos abarcarán un espacio mayor, como cuando nuestros personajes se mueven por un escenario.

Para estos casos necesitamos crear una cámara que siga al personaje, de esa forma se renderizará lo que ve en el viewport.

Para hacer que Camera2D siga a un nodo en movimiento necesitamos un personaje y un escenario más grande que lo que abarca la ventana. Vamos a recuperar el escenario que teníamos anteriormente volviendo a activarlo en su Process:

  • Añadir el Nodo Camera2D: Coloca el nodo Camera2D como hijo del nodo que deseas seguir. Esto podría ser, por ejemplo, tu personaje jugador.
  • Establecer como Cámara Actual: Activa la propiedad Current en el inspector de Godot para hacer de esta cámara la que se usa actualmente en la vista.
  • Ajustar el Camera2D: Coloca la cámara de manera que su posición inicial sea la deseada respecto al nodo padre.

Propiedades Importantes:

  • Drag Margin: Estos márgenes definen cuánto puede moverse el nodo seguido dentro de la vista de la cámara antes de que la cámara comience a seguirlo. Por defecto, todos están configurados al 0.5 (50%), lo que significa que la cámara comenzará a moverse cuando el nodo se mueva fuera del centro de la pantalla. Ajusta estos valores para cambiar cuándo debería la cámara empezar a seguir al nodo.
  • Smoothing: La suavización permite que la cámara siga al nodo de manera más fluida en lugar de replicar cada movimiento de manera exacta e instantánea. Al aumentar el valor de Speed en Smoothing, harás que la cámara se atrase ligeramente detrás del nodo seguido, creando un efecto de seguimiento más suave.
  • Limit: Establece los límites de la cámara para evitar que muestre áreas fuera de tu mundo de juego (como el vacío fuera de los bordes del nivel). Estos límites restringen hasta dónde puede moverse la cámara en las direcciones X e Y.
  • Zoom: Esta propiedad te permite ajustar el nivel de zoom de la cámara. Un valor de Vector2(1, 1) significa que no hay zoom. Valores más bajos acercan la cámara, mientras que valores más altos la alejan.

Un addon muy útil que extiende el funcionamiento de las cámaras es Phantom Camera. Éste implementa dos nodos PhantomCamera2D y PhantomCamera3D, podemos echar un vistazo a las escenas de pruebas para ver su funcionamiento e intentar usarla en nuestro personaje.

30. Cronómetro con timer

En esta lección vamos a introducir el uso de temprizadores, un tipo de Nodo muy útil para programar eventos en el tiempo.

Primero, añadimos un nodo Timer a la escena:

  • Establece Wait Time a 1. Esto hará que el temporizador tenga un intervalo de un segundo.
  • Asegúrate de que One Shot esté desactivado, para que el temporizador se repita.
  • Activa Autostart si deseas que el temporizador comience a contar automáticamente al iniciar la escena.
  • Configurar la señal timeout del temporizador:
extends Node

var segundos = 0  # Contador de segundos

# func _ready():
#     var timer = $Timer  # Asume que el nodo Timer es un hijo directo y se llama "Timer"
#     timer.connect("timeout", self, "_on_Timer_timeout")
#     timer.start()  # Inicia el timer si no has activado Autostart

func _on_Timer_timeout():
    segundos += 1
    print("Tiempo transcurrido: ", segundos, " segundos")

31. Reproducción de audio

En esta lección vamos introducir la reproducción de audio al presionar el botón que creamos anteriormente.

extends Area2D

enum Estados {NORMAL, HOVER, CLIC}
var estado : Estados

# Pre-cargar tus archivos de sonido si aún no lo has hecho
var sonido = preload("res://alarm.ogg")  # new

func _ready():
  estado = Estados.NORMAL
  $AudioStreamPlayer.stream = sound  # new

func _on_input_event(viewport, event, shape_idx):
    if event.is_action_pressed("ui_click"):
        estado = Estados.CLIC
        $AudioStreamPlayer.play(from_position=0.0)  # new
    if event.is_action_released("ui_click"):
        estado = Estados.HOVER
        $AudioStreamPlayer.stop()  # new

32. Alarma de cuenta regresiva

En este experimento vamos a fusionar lo que sabemos sobre temprizadores y reproducción de audio para hacer sonar una alarma programando un temprizador.

  • Añade un nodo Timer a tu escena: Este nodo será el encargado de llevar la cuenta regresiva.
  • Añade un nodo AudioStreamPlayer: Este nodo reproducirá el sonido de la alarma cuando el temporizador llegue a cero.
  • Coloca tu archivo de sonido alarm.ogg en el proyecto: Asegúrate de que el archivo de sonido esté dentro de la carpeta del proyecto de Godot para poder acceder a él desde el nodo AudioStreamPlayer.
  • Selecciona el nodo Timer y ajusta las siguientes propiedades en el inspector:
    • Wait Time: Establece esto al número de segundos para la cuenta regresiva.
    • One Shot: Activa esta opción para que el temporizador solo cuente una vez y no se repita.
  • Selecciona el nodo AudioStreamPlayer y en el inspector, bajo la propiedad Stream, asigna tu archivo alarm.ogg.
  • Adjunta un nuevo script a tu nodo Timer o al nodo padre que contenga tanto el Timer como el AudioStreamPlayer. Asegúrate de exportar una variable para configurar la cuenta regresiva desde el inspector:
extends Node

# Exporta la variable para configurar los segundos de la cuenta regresiva desde el inspector.
@export var segundos_cuenta_regresiva : float = 5.0

# Referencia al nodo AudioStreamPlayer
@onready var alarma = $AudioStreamPlayer

func _ready():
    # Configura el Timer con la duración deseada y lo inicia.
    var timer = $Timer
    timer.wait_time = segundos_cuenta_regresiva
    timer.one_shot = true
    timer.connect("timeout", self, "_on_Timer_timeout")
    timer.start()

func _process(delta):
    var timer = $Timer
    # Obtiene el tiempo restante del Timer y actualiza el título de la ventana con este valor.
    var tiempo_restante = timer.time_left
    DisplayServer.window_set_title("Tiempo restante: " + str(tiempo_restante) + " segundos")

func _on_Timer_timeout():
    # Reproduce el sonido de la alarma cuando el temporizador llega a cero.
    alarma.play()
    # Opcional: resetear el título de la ventana o establecer un mensaje final.
    DisplayServer.set_window_title("¡Tiempo agotado!")

33. Efectos de sonido

Enseñar a utilizar el plugin gdfxr para generar, guardar y reproducir sonidos.

  • Instalar el paquete gdfxr, acitvarlo, crear varios sonidos y Guardarlos como.
  • Añadir un AudioStreamPlayer y asignarle el efecto de sonido asignarles los efectos de sonido.
  • El código para la reproducción es muy simple:
$AudioStreamPlayer.play()
  • Podemos ver la diferencia respecto al AudioStreamPlayer2D, que tiene posicionamiento.
  • Si el reproductor está situado en un nodo a la izquierda del viewport escucharemos el sonido por ese lado.
  • Pero si está situado a la derecha lo escucharemos por el otro altavoz.

34. Cambio de escenas

Crear un script que cambie entre escenas al ocurrir algún evento concreto.

extends Node

func _ready():
    # Esto asegura que el nodo no sea eliminado al cambiar de escena.
    if is_inside_tree():
        get_tree().root.add_child(self)
        self.owner = get_tree().root

func _input(event):
    if event is InputEventKey and event.pressed:
        if event.scancode == KEY_F1:
            change_scene("res://Scene1.tscn")
        elif event.scancode == KEY_F2:
            change_scene("res://Scene2.tscn")
        elif event.scancode == KEY_F3:
            change_scene("res://Scene3.tscn")

func change_scene(scene_path: String): 
    get_tree().change_scene(scene_path)

35. Animaciones con código

Configurar una animación de movimiento en diferentes direcciones con código.

Preparamos el fondo:

  • Añadimos un ParallaxBackground con una ParallaxLayer.
  • En la capa añadimos un Sprite2D de fondo, no centrado y en (0,0).
  • Configuramos el mirroring con el tamaño extra de a repetir (480,120).

Preparamos el personaje:

  • Creamos un CharacterBody2D y le cambiamos el nombre a Personaje.
  • Le añadimos un Sprite2D con todo el spritesheet y Texture en Nearest al ser pixel art.
  • En el Sprite2D activamos la Region y activamos la cuadrícula para seleccionar los 16 sprites.
  • Nota: Si usamos la versión con ropa necesitaremos establecer un offset en el spritesheet:

anim0

  • También activaremos la opción Region > Filter Clip Enabled para evitar artefactos en los bordes.
  • Como la animación son 4 sprites establecemos el Animation > HFrames en 4 y ya lo tenemos listo.
  • Nos llevamos el CharacterBody2D al centro de la escena y escalamos la imagen a (4,4).

Preparamos el script de movimiento:

  • Añadimos un script vacio al CharacterBody2D con el siguiente codigo:
extends CharacterBody2D

@export var speed = 200

func move():
	# Recuperamos un vector normalizado en base a las cuatro direcciones
	var direction := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	# Establecemos la velocidad y movemos al personaje
	velocity = direction * speed
	move_and_slide()

func _physics_process(delta: float):
	move()

Preparamos la primera animación:

  • Añadimos al personaje un AnimationPlayer.
  • Añadimos una nueva animación walk_down.
  • Este nodo permite animar cualquier propiedad, nosotros vamos a ir cambiando el sprite.
  • Dentro del animador seleccionamos el Sprite2D y en el frame hacemos clic en la llave.
  • Le damos a que cree las pistas y se creará una barra de animación en la parte inferior.
  • Como tenemos 4 animaciones vamos a crear 4 key frames cambiando los cuatro frame en el tiempo.
  • Podemos establecer el ajuste a 0.25 segundos para que la animación completa dure 1 segundo.
  • Muy importante 1: Podemos cambiar el orden Mover-Quieto-Mover-Quieto para más fluidez:

anim5

  • Muy importante 2: Activamos el MODO DE ACTUALIZACIÓN a discreto para evitar interpolaciones.

  • Muy importante 3: Activamos el LOOP para que se repitan al finalizar.

  • Una vez tenemos la animación procedemos a controlarla mediante código:

extends CharacterBody2D

@export var speed = 200
@onready var animation_player: AnimationPlayer = $AnimationPlayer  # conservar para evitar bugs

# Preparamos una máquina de estados
enum PlayerState {IDLE, WALK}
var state : PlayerState

func _physics_process(delta: float):
	move()
	animate()

func move():
	# Recuperamos un vector normalizado en base a las cuatro direcciones
	var direction := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	
	# Establecemos el estado de la máquina en base al vector de dirección
	if direction == Vector2.ZERO: state = PlayerState.IDLE
	else: state = PlayerState.WALK
		
	# Establecemos la velocidad y movemos al personaje
	velocity = direction * speed
	move_and_slide()
	
func animate():
	# Comprobamos el estado de la máquina
	match state:
		# Si estamos parados detenemos el animador
		PlayerState.IDLE: 
			animation_player.stop()
		# Si estamos andando reproducimos la animación correspondiente 
		PlayerState.WALK: 
			animation_player.play("walk_down")

Finalizamos las animaciones y adaptamos la máquina de estados:

  • Repetimos el proceso para crear las animaciones walk_up, walk_right y walk_left.
  • Recordemos que las animaciones de caminar duran 1 segundo con tramos de 0.25 para cada imagen.
func animate():
	# Comprobamos el estado de la máquina
	match state:
		# Si estamos parados detenemos el animador
		PlayerState.IDLE: 
			animation_player.stop()
		# Si estamos andando reproducimos la animación correspondiente 
		PlayerState.WALK: 
			match velocity.normalized():
				Vector2.UP: animation_player.play("walk_up")
				Vector2.DOWN: animation_player.play("walk_down")
				Vector2.LEFT: animation_player.play("walk_left")
				Vector2.RIGHT: animation_player.play("walk_right")
  • En este punto tenemos un personaje animado en cuatro direcciones.

36. Animaciones con árboles

Sustituir la animación por código con una máquina de estados en un AnimationTree.

Configurar un árbol de animación:

  • Veamos una forma de manejar las animaciones automáticamente con árboles de animación.
  • En lugar de programar una máquina de estados manualmente podemos hacerlo desde la interfaz.
  • Dentro del CharacterBody2D añadimos un AnimationTree.
  • Creamos un nuevo Tree Root de tipo AnimatioNodeStateMachine.
  • Configuramos el Anim Player al AnimationPlayer del personaje.
  • Si tenemos activado el AnimationMixer ya no podemos reproducir animaciones directamente.
  • En su lugar las vamos a manejar a través del código del propio árbol de animación.

Preparando la máquina de estados:

  • Lo primero que debemos tener en cuenta es que ahora no podemos simplemente detener el animador.
  • Eso significa que necesitamos crear 4 animaciones nuevas para las direcciones del estado Idle.
  • Podemos hacer clonando las que tenemos y dejando el primer sprite idle_up, idle_down, idle_left e idle_right.
  • En este punto diferenciamos dos estados Idle y Walk cada uno con 4 animaciones en base a una dirección.
  • Ambos serán de tipo BlendSpace2D, este espacio 2D permite determinar las animaciones automáticamente tomando un vector.

anim1

Configurando el estado Idle:

  • Por comodidad podemos establecer en la mezcla x para el eje horizontal e y para el eje vertical.
  • Cuando x=1 será idle_right, x=-1 será idle_left, y=1 será idle_down e y=-1 será idle_up.
  • Recordemos que cuando y es positiva la dirección apunta hacia abajo y si es positiva apunta hacia arriba.
  • Por último es muy importante cambiar la mezcla a discreta para evitar la interpolación automática.

anim2

  • Haremos exactamente lo mismo para el estado caminar Walk, configurando las mismas direcciones y la mezcla a discreta.

Configurando el script:

  • La nueva versión del script no requerirá una máquina de estados ya que ésta se gestionará desde el árbol de animación.
  • Lo único que tenemos que hacer es establecer los valores de los vectores de mezcla y cambiar el estado actual dependiendo de si nos movemos o no:
extends CharacterBody2D

@export var speed = 200
@onready var animation_player: AnimationPlayer = $AnimationPlayer  # conservar para evitar bugs
@onready var animation_tree: AnimationTree = $AnimationTree

func _physics_process(delta: float):
	move()

func move():
	# Recuperamos un vector normalizado en base a las cuatro direcciones
	var direction := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	
	# Actualizamos los espacios de mezcla al detectar un movimiento
	if direction != Vector2.ZERO: 
		animation_tree.set("parameters/Idle/blend_position", direction)
		animation_tree.set("parameters/Walk/blend_position", direction)
		# Cambiamos a la animación de caminar manualmente
		animation_tree.get("parameters/playback").travel("Walk")
	else: 
		# Cambiamos a la animación de parado manualmente
		animation_tree.get("parameters/playback").travel("Idle")
		
	# Establecemos la velocidad y movemos al personaje
	velocity = direction * speed
	move_and_slide()
  • En la siguiente lección veremos como automatizar todavía más este sistema.

37. Animaciones condicionadas

Realizar el cambio de estados automáticamente usando condiciones.

  • Anteriormente hemos visto cómo configurar un árbol de animaciones con una máquina de estados manejada mediante código.
  • En esta lección os voy a enseñar cómo automatizar todavía más nuestra máquina de estados utilizando condicionantes.
  • Al hacerlo no necesitaremos decirle explícitamente qué animación debe reproducir sino que lo determinará automáticamente.

Configurando las transiciones:

  • Empezaremos creando una transición del estado Start a Idle, ésta se ejecutará automáticamente al principio.

anim1

  • A continuación vamos a crear otra transición de Idle a Walk.
  • El modo será automático y la condición una expresión is_walking.
  • Añadiremos otra transición de vuelta a Idle con la expresión not is_walking.

anim2

  • A continuación estableceremos el AdvancedExpresionBaseNode como nuestro CharacterBody2D.
  • Al hacerlo le diremos que compruebe ahí la variable is_walking.
  • Solo tenemos que crearla y cambiarla cuando caminemos o dejemos de caminar:directamente los :
@export var is_walking := false

# Actualizamos los espacios de mezcla al detectar un movimiento
if direction != Vector2.ZERO: 
	animation_tree.set("parameters/Idle/blend_position", direction)
	animation_tree.set("parameters/Walk/blend_position", direction)
	# Activamos la transición al estado caminar
	is_walking = true
else:
	# O la desactivamos para volver al estado parado
	is_walking = false
  • Esto incluso se puede simplificar en una sola línea:
# Actualizamos los espacios de mezcla al detectar un movimiento
if direction != Vector2.ZERO: 
	animation_tree.set("parameters/Idle/blend_position", direction)
	animation_tree.set("parameters/Walk/blend_position", direction)

# Establecemos la transición dependiendo de la longitud del vector dirección	
is_walking = direction.length()
  • El resultado final es el mismo pero tiene una ventaja clave.
  • En lugar de hacer código dependiente de los estados del árbol de animaciones, ahora es el árbol de animaciones quien depende de nuestro código y eso nos da un control total a la hora de manejar el sistema de animaciones.

38. Cuadrículas con Tilemaps

En este lección vamos a aprender a manejar el escenario usando cuadriculas con el nodo Tilemap.

  • Añadimos un Tilemap en la parte superior del escenario, lo configuramos a 64x64.
  • Si no se ve el preview hacer clic en la escena y ya se vera bien las líneas.
  • Vamos a crearle un nodo hijo TileMapDebug para debugear diferentes situaciones.
  • Añadirle un script para determinar la posición de clic y la celda:
extends TileMap

func _input(event):
	if event.is_action_pressed("ui_click"):
		# Obtiene la posición del mouse relativa al TileMap
		var mouse_pos = get_local_mouse_position()  
		# Convierte la posición del mundo a coordenadas del mapa (celda)
		var clicked_cell = local_to_map(mouse_pos)  
		print("Posición clicada: ", mouse_pos)
		print("Celda clicada: ", clicked_cell)
  • Es difícil hacernos una idea de lo que esta ocurriendo asi que vamos a debugear la cuadrícula dibujándola:
@export var debug := false

func _draw():
	if debug:
		var viewport_size = get_viewport_rect().size
		var tile_size = tile_set.tile_size
		
		# Color de la cuadrícula, gris claro
		var color = Color(0.3, 0.3, 0.3)  
		
		# Dibujar líneas verticales
		for x in range(0, viewport_size.x, tile_size.x):
			draw_line(Vector2(x, 0), Vector2(x, viewport_size.y), color)

		# Dibujar líneas horizontales
		for y in range(0, viewport_size.y, tile_size.y):
			draw_line(Vector2(0, y), Vector2(viewport_size.x, y), color)

anim1

  • Con el debug en marcha ya es más fácil hacernos una idea de qué está manejando el nodo Tilemap.
  • Pero aún podemos ir más un paso más allá y debugear la coordenada exacta de cada celda en base a su columna y fila.
@export var debug_font : Font

func _input(event):
	if event.is_action_pressed("ui_text_indent"):
		debug = not debug
		queue_redraw() # Llamar de nuevo al método draw al presionar tab

	# ...

func _draw():
	if debug:
		# ...

		# Color del texto negro y tamaño en píxeles
		var text_color = Color(0, 0, 0) 
		var text_size = 16

		# Dibujar el texto
		for x in range(0, viewport_size.x, tile_size.x):
			for y in range(0, viewport_size.y, tile_size.y):
				 # Centrar el texto en la celda
				var cell_position = Vector2(x, y + tile_size.y/2 + text_size/2)
				var column = x / tile_size.x
				var row = y / tile_size.y
				# Formatea los números como strings de dos dígitos
				var text = "(%0d,%0d)" % [column, row]  
				draw_string(debug_font, cell_position, text, 1, tile_size.x, text_size, text_color)

anim2

39. Seguimiento de celdas

En esta lección continuaremos el ejemplo anterior para identificar y debugear celdas especificas.

  • Lo último que quiero hacer es resaltar la celda actual sobre la que el ratón haga clic:
@export var draw_mouse_cell : bool

var selected_mouse_cell := Vector2()

func _input(event):
	# ...
		
	if event.is_action_pressed("ui_click"):
		# ...
		
		# Establecer celda que clicamos
		selected_mouse_cell = clicked_cell
		queue_redraw() # llamar de nuevo al método draw

func _draw():
	if debug:
		# ...
		
		# Si queremos dibujar la celda seleccionada
		if draw_mouse_cell:
			# Convertir coordenadas de celda a posición local
			var cell_position = map_to_local(selected_mouse_cell) - Vector2(tile_size.x/2, tile_size.y/2)
			# Color rojo semitransparente para resaltar
			var highlight_color = Color(1, 0, 0, 0.5)  
			
			# Dibuja un rectángulo sobre la celda actual
			draw_rect(Rect2(cell_position, tile_size), highlight_color, true)

anim3

  • Y aplicando esta lógica incluso podemos resaltar la celda sobre la que se esta moviendo el personaje en todo momento, solo tenemos que resaltar la celda en función de su posición en lugar de la posición del mouse:
@export var draw_character_cell : bool
@export var character : CharacterBody2D

var current_character_cell := Vector2()

func _process(delta):
	current_character_cell = local_to_map(character.position)
	queue_redraw() # llamar de nuevo al método draw

func _draw():
	if debug:
		# ...

		# Si queremos dibujar la celda bajo el personaje
		if draw_character_cell:
			# Convertir coordenadas de celda a posición local
			var cell_position = map_to_local(current_character_cell) - Vector2(tile_size.x/2, tile_size.y/2)
			# Color amarillo semitransparente para resaltar
			var highlight_color = Color(1, 1, 0, 0.5)  
			
			# Dibuja un rectángulo sobre la celda actual
			draw_rect(Rect2(cell_position, tile_size), highlight_color, true)

anim4

  • Refactorizar el código final para hacerlo más entendible y escalable:
extends TileMap

@export var debug := false
@export var debug_font : Font
@export var draw_mouse_cell : bool
@export var draw_character_cell : bool
@export var character : CharacterBody2D

var selected_mouse_cell := Vector2()
var current_character_cell := Vector2()

func _process(delta):
	# Actualiza la celda actual bajo el personaje
	current_character_cell = local_to_map(character.position)
	queue_redraw() # Solicita redibujar el TileMap

func _input(event):
	toggle_debug_mode(event) # Cambia el modo de depuración
	check_mouse_click(event) # Verifica si se hizo clic y actualiza la celda seleccionada

func toggle_debug_mode(event):
	# Alterna el modo de depuración con una tecla
	if event.is_action_pressed("ui_text_indent"):
		debug = not debug
		queue_redraw() # Solicita redibujar para aplicar cambios de depuración

func check_mouse_click(event):
	# Detecta clics y actualiza la celda seleccionada
	if event.is_action_pressed("ui_click"):
		var mouse_pos = get_local_mouse_position()
		var clicked_cell = local_to_map(mouse_pos)
		# Imprime información de la celda
		print_cell_info(mouse_pos, clicked_cell) if debug else null
		selected_mouse_cell = clicked_cell
		queue_redraw()

func print_cell_info(mouse_pos, cell):
	# Imprime información sobre la celda clicada
	print("Posición clicada: ", mouse_pos)
	print("Celda clicada", cell, " Columna: ", cell.x, " Fila: ", cell.y)

func _draw():
	# Dibuja elementos de depuración y resalta celdas según corresponda
	draw_debug_grid_and_text() if debug else null
	highlight_cell(selected_mouse_cell, Color(1, 0, 0, 0.5)) if draw_mouse_cell else null
	highlight_cell(current_character_cell, Color(1, 1, 0, 0.5)) if draw_character_cell else null

func draw_debug_grid_and_text():
	# Dibuja la cuadrícula de depuración y etiquetas de celdas
	var viewport_size = get_viewport_rect().size
	var tile_size = tile_set.tile_size
	draw_grid(viewport_size, tile_size, Color(0.3, 0.3, 0.3))
	draw_grid_text(viewport_size, tile_size, debug_font, 16, Color(0, 0, 0))

func draw_grid(viewport_size, tile_size, color):
	# Dibuja líneas de cuadrícula
	for x in range(0, viewport_size.x, tile_size.x):
		draw_line(Vector2(x, 0), Vector2(x, viewport_size.y), color)
	for y in range(0, viewport_size.y, tile_size.y):
		draw_line(Vector2(0, y), Vector2(viewport_size.x, y), color)

func draw_grid_text(viewport_size, tile_size, font, text_size, color):
	# Dibuja coordenadas de celdas como texto
	for x in range(0, viewport_size.x, tile_size.x):
		for y in range(0, viewport_size.y, tile_size.y):
			var cell_position = Vector2(x, y + tile_size.y/2 + text_size/2)
			var text = "(%02d,%02d)" % [x / tile_size.x, y / tile_size.y]
			draw_string(font, cell_position, text, 1, tile_size.x, text_size, color)

func highlight_cell(cell, color):
	# Resalta una celda específica
	var cell_position = map_to_local(cell) - Vector2(tile_set.tile_size.x/2, tile_set.tile_size.y/2)
	draw_rect(Rect2(cell_position, tile_set.tile_size), color, true)
  • Repasad a fuego este experimento porque lo que os he enseñado es la base para crear videojuegos basados en retículas cuadradas, isométricas y hexagonales.

40. Navegación con Agente

Mover el personaje a una posición de la escena al hacer clic teniendo en cuenta las colisiones.

  • Añadir un NavigationRegion2D con el TileMap en su interior.
  • Ahora sobre el viewport creamos los polígonos y le damos a Crear polígono de Navegación para que genere el área de movimiento del agente. Esto hay que repetirlo si modifamos el tamaño del escenario o añadimos colisiones en el Tilemap tal como veremos en un futuro experimento.
  • Añadir al personaje un nodo NavigationAgent2D
  • Añadir una variable para controlar si se alcanza el punto de destino:
@export var is_target_reached:= true
  • Configurar las dos formas de movernos:
func _physics_process(delta: float):
	if not is_target_reached:
		move_agent()
	else:
		move()
  • Cuando presionamos el raton buscar la posición a la que movernos y establecerla como destino del agente:
func _input(event):
	if event.is_action_pressed("ui_click"):
		# Mover al personaje donde se hace clic
		var mouse_pos = get_global_mouse_position()
		navigation_agent_2d.target_position = mouse_pos
		is_target_reached = false
  • Programar la función de movimiento del agente, que es prácticamente calcada a una función de movimiento normal:
func move_agent():
	# Recuperamos un vector normalizado en base a la siguiente posición del agente
	var direction := (navigation_agent_2d.get_next_path_position() - global_position).normalized()
	
	# Actualizamos los espacios de mezcla al detectar un movimiento
	if direction != Vector2.ZERO: 
		animation_tree.set("parameters/Idle/blend_position", direction)
		animation_tree.set("parameters/Walk/blend_position", direction)
		
	# Establecemos la animación a partir de la longitud del vector dirección	
	is_walking = direction.length()
	
	# Establecemos la velocidad y movemos al personaje
	velocity = direction * speed
	move_and_slide()
  • Configuramos una señal en el agente target_reached para establecer el fin del movimiento automático:
func _on_navigation_agent_2d_target_reached() -> void:
	is_target_reached = true
  • Podemos reducir el Path Desired Distance a 5 para que no se pase de largo en la cantidad de movimiento y activar el Debug > Enabled para visualizar una línea hasta el destino.

animation3-gif

  • Si queremos que el personaje se desplace hasta justo el centro de la celda podemos rectificar la posición recuperando la posición de la celda y sumando el offset hasta el centro:
func _input(event):
	if event.is_action_pressed("ui_click"):
		# Obtener la posición del clic del ratón en coordenadas del mundo
		var mouse_pos = get_global_mouse_position()
		# Convertir esa posición a coordenadas locales del TileMap
		var local_mouse_pos = tile_map.to_local(mouse_pos)
		# Obtener las coordenadas de la celda donde se hizo clic
		var clicked_cell = tile_map.local_to_map(local_mouse_pos)
		# Convertir las coordenadas de la celda de vuelta a coordenadas del mundo
		var cell_center_world_pos = tile_map.map_to_local(clicked_cell)
		# Mover el NavigationAgent2D al centro de la celda clicada
		navigation_agent_2d.target_position = cell_center_world_pos
		is_target_reached = false

animation4-gif

  • Lo que hemos hecho es un movimiento libre pero también es posible realizar un movimiento basado en cuadricula.
  • Sin embargo para hacerlo se necesita implementar un algoritmo de navegación llamado AStarGrid2D. No voy a enseñarlo en esta serie porque su uso es más avanzado y requiere desarrollar mucha lógica personalizada, me lo reservo para un futuro curso centrado en algun videojuego basado en cuadrícula.

41. Escenarios con Tilemaps

Diseñar un escenario utilizando los tiles en Godot._

  • Vamos a diseñar un escenario utilizando las herramientas que nos proveen los Tilemaps.
  • Os voy a facilitar una imagen overworld.png que contiene un tileset de un mundo, descardlo abajo.
  • Lo primero es borrar el nodo ParallaxBackground, ya no lo necesitaremos, crearemos el diseño en Tilemap.
  • Seleccionamos el nodo TileMap y vamos a la sección TileSet de la barra inferior.
  • Le damos a + en la parte inferior y creamos un Atlas a partir de la imagen overworld.png.
  • Configuramos el tamaño de la región a 64x64 y manteniendo la tecla Control seleccionaremos el Tileset completo para que permita utilizar todos los cuadros como tiles:

imagen0

  • Ahora debemos empezar a dibujar las capas del escenario en el apartado Layers del TileMap.
  • A la primera capa le daremos el nombre Fondo .
  • Vamos al apartado TileMap en barra inferior y utilizamos las herramientas de dibujo para crear un prado verde con algun lago en una esquina o lo que queráis, esto solo serán elementos de fondo.
  • Podemos hacerlo seleccionando el primer tile (cuadro verde), dibujando los cuadros uno a uno o trazando rectángulos.

imagen1

  • Una vez lo tengamos veremos que al ejecutar el juego ya no se debugea la información de nuestra cuadrícula, esto es porque el tilemap se dibuja por encima, para solucionarlo podemos crear otro nodo de tipo Node2D como hijo del TileMap con el nombre TileMapDebug.
  • Nota (Este paso deberia haberlo arreglado con anterioridad): Ahora vamos a desconectar el script del TileMap y lo vamos a añadir al TileMapDebug, cambiando el extends a un Node2D y creando una referencia a su nodo padre TileMap, ejecutando de él las funciones como `local_to_map``, etc.
extends Node2D  # <--- 

@export var debug := false
@export var debug_font : Font
@export var draw_mouse_cell : bool
@export var draw_character_cell : bool

@onready var character: CharacterBody2D = $"../../../CharacterBody2D" # <--- 
@onready var tile_map: TileMap = $".." # <--- 

var selected_mouse_cell := Vector2()
var current_character_cell := Vector2()

func _process(delta):
	# Actualiza la celda actual bajo el personaje
	current_character_cell = tile_map.local_to_map(character.position)
	queue_redraw() # Solicita redibujar el TileMap

func _input(event):
	toggle_debug_mode(event) # Cambia el modo de depuración
	check_mouse_click(event) # Verifica si se hizo clic y actualiza la celda seleccionada

func toggle_debug_mode(event):
	# Alterna el modo de depuración con una tecla
	if event.is_action_pressed("ui_text_indent"):
		debug = not debug
		queue_redraw() # Solicita redibujar para aplicar cambios de depuración

func check_mouse_click(event):
	# Detecta clics y actualiza la celda seleccionada
	if event.is_action_pressed("ui_click"):
		var mouse_pos = get_local_mouse_position()
		var clicked_cell = tile_map.local_to_map(mouse_pos)
		
		# Imprime información de la celda
		print_cell_info(mouse_pos, clicked_cell) if debug else null
		selected_mouse_cell = clicked_cell
		queue_redraw()

func print_cell_info(mouse_pos, cell):
	# Imprime información sobre la celda clicada
	print("Posición clicada: ", mouse_pos)
	print("Celda clicada", cell, " Columna: ", cell.x, " Fila: ", cell.y)

func _draw():
	# Dibuja elementos de depuración y resalta celdas según corresponda
	draw_debug_grid_and_text() if debug else null
	highlight_cell(selected_mouse_cell, Color(1, 0, 0, 0.5)) if draw_mouse_cell else null
	highlight_cell(current_character_cell, Color(1, 1, 0, 0.5)) if draw_character_cell else null

func draw_debug_grid_and_text():
	# Dibuja la cuadrícula de depuración y etiquetas de celdas
	var viewport_size = get_viewport_rect().size
	var tile_size = tile_map.tile_set.tile_size
	draw_grid(viewport_size, tile_size, Color(0.3, 0.3, 0.3))
	draw_grid_text(viewport_size, tile_size, debug_font, 16, Color(0, 0, 0))

func draw_grid(viewport_size, tile_size, color):
	# Dibuja líneas de cuadrícula
	for x in range(0, viewport_size.x, tile_size.x):
		draw_line(Vector2(x, 0), Vector2(x, viewport_size.y), color)
	for y in range(0, viewport_size.y, tile_size.y):
		draw_line(Vector2(0, y), Vector2(viewport_size.x, y), color)

func draw_grid_text(viewport_size, tile_size, font, text_size, color):
	# Dibuja coordenadas de celdas como texto
	for x in range(0, viewport_size.x, tile_size.x):
		for y in range(0, viewport_size.y, tile_size.y):
			var cell_position = Vector2(x, y + tile_size.y/2 + text_size/2)
			var text = "(%02d,%02d)" % [x / tile_size.x, y / tile_size.y]
			draw_string(font, cell_position, text, 1, tile_size.x, text_size, color)

func highlight_cell(cell, color):
	# Resalta una celda específica
	var cell_position = tile_map.map_to_local(cell) - Vector2(tile_map.tile_set.tile_size.x/2, tile_map.tile_set.tile_size.y/2)
	draw_rect(Rect2(cell_position, tile_map.tile_set.tile_size), color, true)
  • Una vez tengamos el Debug funcionando vamos a añadir más capas por encima de nuestro TileMap, por ejemplo una capa llamada Fondo Adornos que por defecto aparecerá por encima del Fondo.
  • Si volvemos al TileMap ahora podemos dibujar flores y pequeñas piedras selecionando dicha capa, añadid solo elementos que el personaje pueda pisar, dejad los márgenes de la escena libres por ahora.

imagen2

  • Ahora vamos a añadir una nueva capa llamada Obstáculos, esta capa la vamos a utilizar de límite para que el personaje no pueda cruzar la zona. Lo haremos con árboles, troncos y piedras grandes:

imagen3

  • Como véis la clave consiste en dibujar primero las capas del fondo y luego ir añadiendo más por encima.
  • En la próxima práctica acabaremos de pulir el experimento añadiendo las colisiones.

42. Colisiones en Tilemaps

Implementar las colisiones entre el personaje, los obstáculos, edificios, etc..

  • Para añadir colisiones a nuestro TileMap el primer paso es crear una capa de físicas Physics Layers en el propio TileSet, para ello lo tenemos que desplegarlo.
  • Una vez añadida iremos al apartado TileSet > Pintar y en Propiedades de Pintura seleccionaremos Capa de Fisica 0.
  • Ahora podremos pintar sobre los tiles las áreas de colisión que queramos. Dibujad áreas a la piedra, al árbol y a la casa, pero dejad la puerta de la casa libre:

imagen5

  • Ahora añadimos una CollisionShape2D circular al personaje, de 20px justo en su centro, esto es importante:

imagen6

  • Y listo, las colisiones están implementadas y podemos movernos con las teclas por el escenario:

animation7-gif

  • Por defecto el CharacterBody2D detecta cuando hay alguna colisión cuerpos físicos en los tiles y ahí sucede la magia.
  • Sin embargo nuestro agente de navegación no funcionará correctamente en este punto.
  • Para que funcione necesitamos que la NavigationRegion2D detecte los tiles con físicas y genere una malla de navegación específica.
  • Si presionamos el botón "Crear polígono de navegación" no funcionará porque éste solo detecta colisiones en la primera capa del TileMap.
  • En otras palabras, necesitamos establecer al principio de todo la capa Obstáculos para que concuerde con la primera posición:

imagen8

  • EL problema es que al hacerlo dejaremos de ver los obstáculos en la pantalla. Podemos arreglar esto dibujando la capa Obstáculos por encima de las otras aunque realmente sea la primera.
  • Esto lo conseguiremos cambiando el Z-Index de cada capa manualmente, por ejemplo Obstáculos - 2, Fondo - 0, Fondo Adornos - 1:

imagen9

  • Dado que hemos cambiado la profundidad de dibujado de las capas vamos a configurar al personaje para que se dibuje por encima de ellas estableciendo al CharacterBody2D un Ordering > Z-Index más alto, por ejemplo de 10.
  • En este punto si seleccionamos el NavigationRegion2D y Recreamos el polígono de navegación veremos que se crea una región por la que se puede mover el personaje al hacer clic.

imagen10

  • Si probamos el juego nuestro personaje se quedará atorado en las esquinas de las colisiones:

animation11

  • Para solucionarlo vamos a configurar el radio de los agentes de navegación del NavigationRegion2D > Agents a 20px, lo mismo que mide el círculo de nuestra colisión.
  • Volvemos a recrear el polígono de navegación, ahora se verá que queda menos espacio entre los tiles:

imagen12

  • ¡Y problema solucionado!

animation12

  • Espero que os haya gustado esta introducción a los Tilemaps y la navegación con agentes.
  • Con esto acabamos el capítulo de experimentos interactivos y damos paso a uno de experimentos con interfaces gráficas, os espero!