Tutorial de Godot – Parte 10: Monstruos e Inteligencia Artificial

Si siguió todos los tutoriales de esta serie , en este punto todo lo que tiene es un personaje que puede moverse libremente en un mapa y una GUI simple. Por supuesto, debes estar satisfecho de haber logrado este resultado (especialmente si es la primera vez que creas un juego), pero ¿dónde está la verdadera diversión de un juego de rol si no hay monstruos que cazar?

En este tutorial, vamos a agregar monstruos a nuestro juego. Les daremos una inteligencia artificial muy básica : lo único que pueden hacer es perseguir al jugador por el mapa cuando está cerca, o moverse de forma aleatoria.

Crearemos el objeto monstruo (que, por cierto, será un esqueleto) como una escena separada, que instanciaremos en múltiples copias dentro de la escena principal. Por último, también veremos cómo crear un spawner, es decir, un nodo que se encarga de agregar monstruos automáticamente a la escena.

Pasos preparatorios

Abramos el proyecto y retomemos donde lo dejamos en el último tutorial.

Dado que agregaremos dos nuevas entidades, crearemos las carpetas respectivas en el panel Sistema de archivos. En la carpeta Entities , cree las carpetas Skeleton y SkeletonSpawner :

Ahora, descargue las imágenes que usaremos para el esqueleto presionando el botón a continuación.

Descargar «Marcos de esqueleto SimpleRPG»esqueleto_frames.zip – 14 KB

Una vez descargados, impórtelos a la carpeta Skeleton . Luego, seleccione todas las imágenes y, en el panel Importar , deseleccione Banderas → Filtrar y vuelva a importarlas.

Una vez hecho esto, podemos pasar a crear la escena del esqueleto.

Creando la escena del esqueleto

Cree una nueva escena haciendo clic en el menú Escenas → Nueva escena :

Necesitamos agregar un nodo raíz a esta nueva escena. Dado que nuestro monstruo se moverá y chocará con otros nodos en el mapa, usaremos un KinematicBody2D como nodo raíz, tal como lo hicimos con el jugador. Entonces, en el panel Escenas, haga clic en el botón Nodo personalizado y agregue un nodo KinematicBody2D .

Cambie el nombre del nodo a Skeleton . Verá una advertencia que indica que falta la forma de colisión. Lo agregaremos más tarde.

Para dibujar el monstruo en la pantalla, agregue un nodo AnimatedSprite a Skeleton . Con el nodo AnimatedSprite seleccionado, en el Inspector , haga clic junto a la propiedad Frames y seleccione nuevos SpriteFrames para crear el recurso que contendrá las animaciones del esqueleto.

Haga clic en el recurso recién creado para abrir el editor de SpriteFrames y crear las animaciones que se enumeran en la siguiente tabla. Si no recuerdas cómo hacerlo, echa un vistazo al tutorial 2D Sprite Animation .

NombreMarcosVelocidad (FPS)Círculo
nacimientoesqueleto_muerte_6.png
esqueleto_muerte_5.png
esqueleto_muerte_4.png
esqueleto_muerte_3.png
esqueleto_muerte_2.png
esqueleto_muerte_1.png
7Apagado
muerteesqueleto_muerte_1.png
esqueleto_muerte_2.png
esqueleto_muerte_3.png
esqueleto_muerte_4.png
esqueleto_muerte_5.png
esqueleto_muerte_6.png
7Apagado
ataque_hacia abajoesqueleto_abajo_inactivo_1.png
esqueleto_abajo_ataque_1.png
esqueleto_abajo_ataque_2.png
3Apagado
inactivoesqueleto_abajo_inactivo_1.png
esqueleto_abajo_inactivo_2.png
1Sobre
Abajo_caminaresqueleto_abajo_inactivo_1.png
esqueleto_abajo_caminar_1.png
esqueleto_abajo_inactivo_1.png
esqueleto_abajo_caminar_2.png
5Sobre
ataque_izquierdaesqueleto_izquierda_inactivo_1.png
esqueleto_izquierda_ataque_1.png
esqueleto_izquierda_ataque_2.png
3Apagado
izquierda_inactivaesqueleto_izquierda_inactivo_1.png
esqueleto_izquierda_inactivo_2.png
1Sobre
izquierda_caminaresqueleto_izquierda_inactivo_1.png
esqueleto_izquierda_caminar_1.png
esqueleto_izquierda_inactivo_1.png
esqueleto_izquierda_caminar_2.png
5Sobre
derecha_ataqueesqueleto_derecho_inactivo_1.png
esqueleto_derecho_ataque_1.png
esqueleto_derecho_ataque_2.png
3Apagado
derecho_inactivoesqueleto_derecho_inactivo_1.png
esqueleto_derecho_inactivo_2.png
1Sobre
derecho_caminaresqueleto_derecho_inactivo_1.png
esqueleto_derecho_caminar_1.png
esqueleto_derecho_inactivo_1.png
esqueleto_derecho_caminar_2.png
5Sobre
up_attackesqueleto_arriba_inactivo_1.png
esqueleto_arriba_ataque_1.png
esqueleto_arriba_ataque_2.png
3Apagado
up_idleesqueleto_arriba_inactivo_1.png
esqueleto_arriba_inactivo_2.png
1Sobre
subir_caminaresqueleto_arriba_inactivo_1.png
esqueleto_arriba_caminar_1.png
esqueleto_arriba_inactivo_1.png
esqueleto_arriba_caminar_2.png
5Sobre

Por ahora, establezca down_idle como la animación predeterminada. Luego, establezca la propiedad Índice Z de AnimatedSprite en 1.

Agregue un nodo CollisionShape2D a Skeleton y, en el Inspector , establezca la propiedad Shape en un nuevo recurso RectangleShape2D . Luego, cambia el tamaño del rectángulo para cubrir el cuerpo del esqueleto.

Lo último que se debe agregar a Skeleton es un nodo de temporizador . Usaremos este temporizador para comprobar periódicamente el estado del esqueleto y decidir su comportamiento (perseguir al jugador o moverse aleatoriamente). Con el Temporizador seleccionado, en el Inspector establezca el Tiempo de espera en 0,25 segundos y habilite el inicio automático .

Ahora haga clic en Escena → Guardar escena o presione Control + S para guardar la escena. Llámelo Skeleton.tscn y guárdelo en la carpeta Entities/Skeleton .

Esqueleto Inteligencia Artificial

La Inteligencia Artificial del esqueleto será muy sencilla. Si el jugador está a cierta distancia, el esqueleto se moverá hacia él. De lo contrario, decidirá aleatoriamente si quedarse quieto, cambiar la dirección del movimiento o continuar en la dirección actual. Si el esqueleto entra en contacto con un obstáculo, cambiará de dirección casualmente en un intento de sortearlo.

Adjunte un script al nodo Skeleton . Llámelo Skelton.gd y guárdelo en la carpeta Entitites/Skeleton , luego ábralo.

Primero, agreguemos todas las variables que necesitaremos:

# Node references
var player

# Random number generator
var rng = RandomNumberGenerator.new()

# Movement variables
export var speed = 25
var direction : Vector2
var last_direction = Vector2(0, 1)
var bounce_countdown = 0

Esta es la lista de variables y para qué se utilizan:

  • player : es una referencia al nodo Player . Lo necesitaremos para obtener la posición actual del jugador.
  • rng : es un objeto de tipo RandomNumberGenerator. Como su nombre lo dice, es una clase para generar números pseudoaleatorios. El método new() se utiliza para crear un objeto a partir de una clase.
  • velocidad : es la velocidad de movimiento del esqueleto. La palabra clave export hace que esta variable sea visible en el Inspector .
  • dirección : es la dirección de movimiento actual del esqueleto.
  • last_direction : es la última dirección de movimiento antes de detenerse. Se utiliza para elegir la animación inactiva correcta.
  • bounce_countdown : cuando el esqueleto golpea un obstáculo, cambia de dirección durante un cierto tiempo para intentar rodearlo. Esta variable es la cuenta regresiva utilizada para restaurar el comportamiento predeterminado.

Cuando el esqueleto ingresa al árbol de escena, debemos hacer dos cosas:

  • obtener la referencia del nodo Player
  • Inicializar el generador de números aleatorios

Debemos realizar estas operaciones dentro de la función _ready() :

func _ready():
	player = get_tree().root.get_node("Root/Player")
	rng.randomize()

La función get_tree() devuelve la jerarquía de nodos actual como un objeto SceneTree . Usando la propiedad raíz de SceneTree , obtenemos una referencia a la ventana gráfica raíz , a la que podemos solicitar una referencia de nodo pasando su ruta a la función get_node() .

El método randomize() de RandomNumberGenerator utiliza una semilla basada en el tiempo para inicializar el generador de números aleatorios.

Como dijimos antes, queremos usar el temporizador para decidir periódicamente el comportamiento del esqueleto. Básicamente, queremos ejecutar una función cada vez que se activa el temporizador. Para hacer esto, necesitamos conectar la señal Timer timeout() a nuestro script.

En el panel Nodo , haga doble clic en timeout() , seleccione el nodo Skeleton y presione Conectar . La función para gestionar la señal del temporizador se creará automáticamente.

Reemplace el código de la función con esto:

func _on_Timer_timeout():
	# Calculate the position of the player relative to the skeleton
	var player_relative_position = player.position - position
	
	if player_relative_position.length() <= 16:
		# If player is near, don't move but turn toward it
		direction = Vector2.ZERO
		last_direction = player_relative_position.normalized()
	elif player_relative_position.length() <= 100 and bounce_countdown == 0:
		# If player is within range, move toward it
		direction = player_relative_position.normalized()
	elif bounce_countdown == 0:
		# If player is too far, randomly decide whether to stand still or where to move
		var random_number = rng.randf()
		if random_number < 0.05:
			direction = Vector2.ZERO
		elif random_number < 0.1:
			direction = Vector2.DOWN.rotated(rng.randf() * 2 * PI)
	
	# Update bounce countdown
	if bounce_countdown > 0:
		bounce_countdown = bounce_countdown - 1

Lo primero que hace esta función es calcular la posición del jugador en relación con el esqueleto. Este vector se utilizará para determinar la distancia desde el jugador y para establecer la dirección del movimiento y la orientación del esqueleto.

Luego, la función verifica la distancia del esqueleto del jugador. Si está dentro de los 16 píxeles, el esqueleto está en contacto con el jugador y no se mueve ( la dirección se establece en cero), pero la variable last_direction se establece para girar el esqueleto hacia el jugador.

Si la distancia es superior a 16, pero dentro de los 100 píxeles, entonces el esqueleto persigue al jugador, estableciendo su dirección de movimiento hacia él.

Además de la distancia, la función comprueba si bouce_countdown es igual a 0. Si no es así, se ignora el comportamiento predeterminado y el esqueleto se mueve en la última dirección (que, como veremos más adelante, se establece después de llamar a move_and_collide() si hay colisión).

La última posibilidad es que el jugador esté demasiado lejos del monstruo. En ese caso, la función randf() se utiliza para generar un número aleatorio entre 0 y 1. Si el número es inferior a 0,05, el esqueleto se detiene; si es mayor que 0,05 y menor que 0,1, el esqueleto se moverá en una dirección generada aleatoriamente. Esta dirección se obtiene girando Vector2.DOWN en un ángulo aleatorio entre 0 y 2π radianes (0 a 360°). Para cualquier otro valor del número aleatorio, el esqueleto mantiene el comportamiento anterior.

Entonces, en la práctica, en cada tiempo de espera del temporizador , hay un 5% de probabilidad de que el esqueleto se detenga y un 5% de probabilidad de que cambie la dirección del movimiento (siempre que el jugador esté fuera de alcance).

La última función que necesitamos implementar para completar el esqueleto de IA es _physics_process() . Como ya hemos visto en el tutorial de movimiento del jugador , esta es la función en la que se recomienda poner código relacionado con la física, incluido el movimiento de los cuerpos físicos. Introduzca este código en el script:

func _physics_process(delta):
	var movement = direction * speed * delta
	
	var collision = move_and_collide(movement)
	
	if collision != null and collision.collider.name != "Player":
		direction = direction.rotated(rng.randf_range(PI/4, PI/2))
		bounce_countdown = rng.randi_range(2, 5)

En _physics_process() , la función move_and_collide() se usa para mover el esqueleto, exactamente de la misma manera que la usamos para mover al jugador. En este caso, sin embargo, también usamos su valor devuelto (un objeto KinematicCollision2D ) para determinar si ha habido una colisión con un cuerpo que no sea Player .

Si esto sucede, la dirección de movimiento actual gira en un ángulo generado aleatoriamente obtenido mediante la función randf_range() . Este ángulo tiene un valor entre π/4 y π/2 radianes (45° a 90°). Además, se genera aleatoriamente un número entero entre 2 y 5 (usando la función randi_range() ), para ser usado como cuenta regresiva para el “rebote” en el obstáculo.

Ahora que la IA está completa, cree una instancia de algunos monstruos en la escena arrastrando Skeleton.tscn en el nodo raíz e intente ejecutar el juego para probarlos. Si lo desea, puede jugar con los valores dentro de las funciones anteriores, para ajustar el comportamiento de los esqueletos.

Sprite de esqueleto animado

Para animar los esqueletos, utilizaremos dos funciones que copiaremos casi exactamente de Player :

func get_animation_direction(direction: Vector2):
	var norm_direction = direction.normalized()
	if norm_direction.y >= 0.707:
		return "down"
	elif norm_direction.y <= -0.707:
		return "up"
	elif norm_direction.x <= -0.707:
		return "left"
	elif norm_direction.x >= 0.707:
		return "right"
	return "down"

func animates_monster(direction: Vector2):
	if direction != Vector2.ZERO:
		last_direction = direction
		
		# Choose walk animation based on movement direction
		var animation = get_animation_direction(last_direction) + "_walk"
		
		# Play the walk animation
		$AnimatedSprite.play(animation)
	else:
		# Choose idle animation based on last movement direction and play it
		var animation = get_animation_direction(last_direction) + "_idle"
		$AnimatedSprite.play(animation)

La única función modificada es animates_monster() , y solo hay algunos cambios en comparación con la versión utilizada en Player :

  • El nombre de la función.
  • el nombre del nodo AnimatedSprite.
  • la eliminación de la línea que establece el FPS de la animación en función de la velocidad (los esqueletos se mueven a una velocidad constante).

Para obtener detalles sobre cómo funcionan estas funciones, consulte el tutorial 2D Sprite Animation .

Hay otras animaciones además de idle y walk , por ejemplo birth , que no deberían ser interrumpidas por la ejecución de la función animates_monster() . Para no interrumpirlos, necesitamos una variable que nos indique si se está reproduciendo una de estas animaciones:

# Animation variables
var other_animation_playing = false

Finalmente, agreguemos este código a _physics_process() para reproducir animaciones de caminata e inactividad :

# Animate skeleton based on direction
if not other_animation_playing:
	animates_monster(direction)

Ejecute el juego ahora para ver esqueletos inactivos y animaciones para caminar.

Generador de esqueletos

Para probar los esqueletos, los hemos agregado manualmente a nuestra escena. Sin embargo, durante el juego queremos usar un sistema que genere automáticamente los enemigos del jugador , para que nunca caigan por debajo de un cierto número, incluso después de haber sido asesinados. Entonces, vamos a crear lo que llamamos spawner , es decir, un nodo que maneje la creación de instancias de monstruos cuando sea necesario.

En primer lugar, elimine todos los esqueletos que haya agregado manualmente a la escena principal.

Luego, abre la escena Skeleton.tscn , selecciona el nodo Timer y en el Inspector desactiva Autostart . Iniciaremos el temporizador desde el script cuando instanciamos los esqueletos. No podemos tener el temporizador activo de inmediato, porque los esqueletos comenzarían a moverse antes de completar el proceso de desove.

Cree una nueva escena para nuestro reproductor haciendo clic en Escena → Nueva escena . Tener el spawner en una escena separada nos permitirá, si lo deseamos, instanciar más de un spawner en la escena principal.

El nodo raíz de esta escena puede ser simplemente un Nodo2D , por lo que en el panel Escena , haga clic en Escena 2D :

Cambie el nombre de este nodo a SkeletonSpawner y agréguele un nodo de temporizador . Con el temporizador seleccionado, habilite el inicio automático en el Inspector y deje el tiempo de espera en 1 segundo.

Guarda la escena con el nombre SkeletonSpawner.tscn dentro de la carpeta Entities/SkeletonSpawner . Ahora adjunte un script en el nodo SkeletonSpawner y guárdelo como SkeletonSpawner.gd en la misma carpeta que antes.

Guión generador

Comencemos agregando todas las variables que necesitaremos:

# Nodes references
var tilemap
var tree_tilemap

# Spawner variables
export var spawn_area : Rect2 = Rect2(50, 150, 700, 700)
export var max_skeletons = 40
export var start_skeletons = 10
var skeleton_count = 0
var skeleton_scene = preload("res://Entities/Skeleton/Skeleton.tscn")

# Random number generator
var rng = RandomNumberGenerator.new()

Así es como usaremos estas variables:

  • tilemap, tree_tilemap : en estas variables almacenaremos las referencias a los tilemaps . Cuando coloquemos los monstruos de forma aleatoria, utilizaremos tilesmaps para saber si hay algún obstáculo en la posición elegida.
  • spawn_area : es un rectángulo que representa el área en la que queremos que aparezcan los monstruos.
  • max_skeletons : es el número máximo de esqueletos que pueden estar en el mapa al mismo tiempo. Tenga en cuenta que este valor es para un solo reproductor.
  • start_skeletons : es la cantidad de esqueletos creados al comienzo del juego. Tenga en cuenta que este valor es para un solo reproductor.
  • skeleton_count : es el número de esqueletos (creados por este generador) actualmente presentes en el mapa.
  • skeleton_scene : es el recurso que representa la escena Skeleton. La función preload() devuelve un recurso del sistema de archivos que se carga durante el análisis del script.
  • rng : un objeto RandomNumberGenerator , utilizado para generar aleatoriamente las posiciones donde colocar nuevos esqueletos.

El generador creará nuevos esqueletos en dos momentos diferentes: al comienzo del juego y en cada tiempo de espera. Como necesitaremos el código para instanciar esqueletos en más de una ocasión, deberíamos escribirlo dentro de una función:

func instance_skeleton():
	# Instance the skeleton scene and add it to the scene tree
	var skeleton = skeleton_scene.instance()
	add_child(skeleton)
	
	# Place the skeleton in a valid position
	var valid_position = false
	while not valid_position:
		skeleton.position.x = spawn_area.position.x + rng.randf_range(0, spawn_area.size.x)
		skeleton.position.y = spawn_area.position.y + rng.randf_range(0, spawn_area.size.y)
		valid_position = test_position(skeleton.position)

	# Play skeleton's birth animation
	skeleton.arise()

Primero, la función crea una instancia de skeleton_scene utilizando la función instance() . Esta función devuelve una escena de tipo Skeleton , que agregamos a la jerarquía de nodos usando la función add_child() .

Ahora, debemos colocar el esqueleto en un punto aleatorio dentro del área de generación. Sin embargo, no todas las posiciones en el mapa son válidas para la colocación de un esqueleto. Por ejemplo, no podemos colocar al monstruo en el agua o sobre un obstáculo. Si obtenemos una posición no válida, debemos regenerarla hasta que obtengamos una válida.

Para hacer esto, usamos un bucle while . La instrucción while es una instrucción de flujo de control que permite que el código se ejecute repetidamente, en función de una condición booleana determinada. En nuestro caso, repetiremos el código interior siempre que posición_válida sea ​​falsa (y, en consecuencia, la condición posición_no_válida sea ​​verdadera). Dentro del bucle while , generamos una posición aleatoria y la comprobamos con la función test_position() , que crearemos en breve. Si test_position() devuelve verdadero, entonces la posición es válida; de lo contrario, no lo es.

La última línea de instance_skeleton() llama a la función rise() de Skeleton . Crearemos esta función más adelante. Simplemente reproducirá la animación de nacimiento del esqueleto.

Agregue la función test_position() al script:

func test_position(position : Vector2):
	# Check if the cell type in this position is grass or sand
	var cell_coord = tilemap.world_to_map(position)
	var cell_type_id = tilemap.get_cellv(cell_coord)
	var grass_or_sand = (cell_type_id == tilemap.tile_set.find_tile_by_name("Grass")) || (cell_type_id == tilemap.tile_set.find_tile_by_name("Sand"))
	
	# Check if there's a tree in this position
	cell_coord = tree_tilemap.world_to_map(position)
	cell_type_id = tree_tilemap.get_cellv(cell_coord)
	var no_trees = (cell_type_id != tilemap.tile_set.find_tile_by_name("Tree"))
	
	# If the two conditions are true, the position is valid
	return grass_or_sand and no_trees

Esta función toma como argumento la posición del mapa a consultar. El método world_to_map() de tilemap devuelve las coordenadas de celda correspondientes a la posición local dada. Usamos esas coordenadas como argumento de la función get_cellv() , que devuelve un índice que identifica el tipo de mosaico en esa celda. Para averiguar el índice correspondiente a un cierto tipo de mosaico, usamos la función find_tile_by_name() de TileSet . Luego comparamos los índices para saber si la celda es de tipo Hierba o Arena (las únicas que sirven para posicionar esqueletos).

Repetimos el mismo procedimiento con tree_tiles , esta vez para comprobar que no hay árboles en esa posición. Si ambas pruebas tienen éxito, la posición es válida y la función devuelve verdadero.

Instanciación de esqueletos

Dijimos anteriormente que necesitamos instanciar esqueletos en dos ocasiones. El primero es al inicio, por lo que debemos ingresar el código de creación de instancias en la función _ready() , junto con otro código de inicialización:

func _ready():
	# Get tilemaps references
	tilemap = get_tree().root.get_node("Root/TileMap")
	tree_tilemap = get_tree().root.get_node("Root/Tree TileMap")
	
	# Initialize random number generator
	rng.randomize()
	
	# Create skeletons
	for i in range(start_skeletons):
		instance_skeleton()
	skeleton_count = start_skeletons

Las dos primeras líneas recuperan las referencias a los mapas de mosaicos de la escena principal, utilizados en el método test_position() . La siguiente línea inicializa el generador de números aleatorios.

El código inmediatamente después es el que realmente maneja la creación de instancias de monstruos. La declaración for es una declaración de flujo de control que se usa para iterar a través de un rango. La función range() , cuando se usa con un solo argumento n , devuelve todos los números enteros entre 0 y n-1 . Entonces, en nuestro caso, el bucle for ejecuta la función instance_skeleton() un número de veces igual al valor de start_skeletons , instanciando así ese número de esqueletos.

Además, queremos crear un esqueleto nuevo cada segundo, a menos que el recuento de esqueletos ya sea igual o mayor que max_skeletons .

Vaya al panel Node y conecte la señal Timer timeout() de SkeletonSpawner al script. La función para manejar la señal se agregará automáticamente al script. Reemplace el código con esto:

func _on_Timer_timeout():
	# Every second, check if we need to instantiate a skeleton
	if skeleton_count < max_skeletons:
		instance_skeleton()
		skeleton_count = skeleton_count + 1

Completando el guión de Skeleton

Ahora que hemos terminado con el engendrador, volvamos a Skeleton y agreguemos la función que falta rise() al script:

func arise():
	other_animation_playing = true
	$AnimatedSprite.play("birth")

Si añadiéramos al spawner a la escena principal y probáramos el juego ahora, veríamos que, tras reproducir la animación del nacimiento, los esqueletos se quedarían atascados en su último fotograma. Esto se debe a que, al final de la animación, tenemos que hacer dos cosas más:

  • iniciar el temporizador para habilitar la Inteligencia Artificial
  • establezca en false la variable other_animation_playing para permitir que se reproduzcan las animaciones de caminar e inactivas .

Entonces, conecte la señal animation_finished() de AnimatedSprite al script y reemplace la función creada automáticamente con este código:

func _on_AnimatedSprite_animation_finished():
	if $AnimatedSprite.animation == "birth":
		$AnimatedSprite.animation = "down_idle"
		$Timer.start()
	other_animation_playing = false

Usando el generador

Abra la escena principal y arrastre SkeletonSpawner.tscn al nodo raíz . Para observar más fácilmente cómo funciona el generador, con SkeletonSpawner seleccionado, en el Inspector establezca el tamaño del Área de generación en 200, 100.

Ejecuta el juego para ver al generador en acción.¡Este no es un lugar seguro!

Cuando haya probado que todo funciona, recuerde restablecer el Área de aparición a los valores predeterminados, haciendo clic en el icono de reinicio:

Conclusiones

En este tutorial hemos aprendido muchas cosas útiles que a menudo encontrarás al desarrollar juegos en Godot:

  • Cómo crear instancias de escenas, ya sea manualmente o por código.
  • Cómo obtener nodos de la jerarquía de nodos, usando las funciones get_tree() y get_node() .
  • Cómo usar bucles while y for
  • Cómo generar números aleatorios
  • Cómo usar los temporizadores