Tutorial de Godot – Parte 14: NPCs, Misiones y Diálogos

En este tutorial, presentaremos algunos elementos centrales de los juegos de rol:

  • Personaje no jugador : un PNJ es un personaje que no está controlado por el jugador. En los videojuegos, el comportamiento de los NPC suele estar programado y es automático, desencadenado por ciertas acciones o diálogos con el jugador.
  • Misiones : una misión es una tarea que el jugador puede completar para obtener una recompensa. Las recompensas incluyen puntos de experiencia, artículos, moneda del juego, acceso a nuevas ubicaciones o cualquier combinación de los anteriores. Las misiones se pueden dividir en cuatro categorías (misiones de muerte, misiones de recolección, misiones de entrega y misiones de escolta), que se pueden combinar para crear misiones más elaboradas.
  • Diálogos : conversar con NPC es una de las principales formas de guiar al jugador a través de juegos basados ​​en historias como los juegos de rol. Al hablar con los NPC, el jugador puede aprender más sobre el mundo del juego, obtener pistas y recibir nuevas misiones para completar.

Para nuestro juego crearemos un NPC llamado Fiona. Fiona ha perdido su collar y le pedirá al jugador que lo encuentre. Como recompensa, le dará al jugador una poción de salud (el jugador también obtendrá algunos puntos de experiencia).

Descarga y organización de archivos.

Descarga los sprites para este tutorial haciendo clic en el botón de abajo:

Descargar «Personaje no jugador SimpleRPG»non_player_character.zip – 2 KB

Luego, en el panel Sistema de archivos, dentro de la carpeta Entidades , cree dos carpetas nuevas: Fiona y Collar .

Importe las imágenes que comienzan con npc_ en la carpeta Fiona y la imagen necklage.png en la carpeta Collar . Recuerda reimportar las imágenes desactivando Filter .

árbol de diálogo

Para hablar con los NPC, la mayoría de los juegos usan un sistema de diálogo ramificado. Esta mecánica de juego se llama árbol de diálogo .

En este sistema de diálogo, el jugador elige qué decir de una lista de opciones escritas previamente y el NPC responde al jugador . El ciclo continúa hasta que finaliza la conversación. Los factores externos, como las estadísticas del jugador o los elementos del inventario, pueden influir en las respuestas del NPC o desbloquear nuevas opciones de diálogo.

Las misiones y los diálogos se pueden implementar como una máquina de estados finitos . Para la búsqueda de Fiona, es así:

Esta búsqueda puede estar en cualquiera de estos estados (cuadros redondeados):

  • No iniciado : el jugador aún no ha aceptado la misión;
  • Iniciado : el jugador ha aceptado la misión de Fiona, pero aún no le ha devuelto el collar;
  • Completado : el jugador le devolvió el collar a Fiona.

En cada uno de estos estados, hay un árbol de diálogo que representa los posibles intercambios de conversación entre Fiona y el jugador.

Cada oración de Fiona (recuadros rectangulares) está numerada; Usaremos este número en el guión de Fiona para seguir el progreso de la conversación. Todas las respuestas posibles del jugador están representadas por una flecha que lleva a la siguiente oración de Fiona (o al final de la conversación).

Algunas respuestas (flechas rojas) hacen que la Búsqueda cambie de estado. Cuando la misión alcanza un nuevo estado, los diálogos de los estados anteriores ya no son accesibles.

Creación de una escena de personaje no jugador

Crea una nueva escena para el NPC. Elija Nodo personalizado como nodo raíz para la escena y configúrelo en un nodo StaticBody2D . Elegimos este tipo de nodo en lugar de KinematicBody2D porque necesitamos detectar colisiones pero no necesitamos que el NPC se mueva.

Cambie el nombre del nodo raíz a Fiona y luego vaya al panel Nodo → Grupos . Agregue un nuevo grupo llamado NPC :

Los grupos en Godot funcionan como etiquetas que pueden usarse para organizar nodos en una escena . En este tutorial solo tendremos un NPC, pero si desea crear otros, agruparlos le permitirá reutilizar parte del código que escribiremos sin modificarlo.

En el Inspector , establezca la propiedad Nodo → Pausa en Proceso . De esta forma, las animaciones de Fiona seguirán reproduciéndose incluso cuando el juego esté en pausa.

Agregue un nodo AnimatedSprite a Fiona . En el Inspector , establezca la propiedad Frames en New SpriteFrames .

Luego, haga clic en los SpriteFrames recién creados para abrir el Editor de SpriteFrames . Crea estas animaciones:

NombreMarcosVelocidad (FPS)Círculo
inactivonpc_inactivo_1.png
npc_inactivo_2.png
1Sobre
hablarnpc_idle_1.png
npc_talk.png
8Sobre

Si no recuerda cómo usar SpriteFrames Editor , consulte el tutorial de animación 2D Sprite .

Cuando haya terminado de crear estas animaciones, en el Inspector establezca la propiedad Animación en inactiva y active la propiedad Reproducción .

Agregue un nodo CollisionShape2D a Fiona y, en el Inspector , establezca su propiedad Shape en New RectangleShape2D . Luego, cambia el tamaño del rectángulo para cubrir solo el sprite de Fiona .

Ahora, adjunte un nuevo script a Fiona . Guárdelo como Fiona.gd en la carpeta Entities/Fiona . Luego, agregue este código al script:

enum QuestStatus { NOT_STARTED, STARTED, COMPLETED }
var quest_status = QuestStatus.NOT_STARTED
var dialogue_state = 0
var necklace_found = false

En las dos primeras líneas declaramos una enumeración ( QuestStatus ) que enumera todos los estados de la búsqueda y la variable quest_status para almacenar el estado actual.

La variable dialog_state se utiliza para almacenar el estado actual del diálogo (los números en los recuadros que hemos visto anteriormente en el árbol de diálogo).

Finalmente, la variable necklace_found almacena si el jugador ha encontrado el collar de Fiona o no.

Dejemos este script por ahora (lo completaremos más tarde cuando hayamos creado el cuadro de diálogo emergente) y guardemos la escena como Fiona.tscn dentro de la carpeta Entities/Fiona .

Ahora regrese a la escena principal y arrastre la escena Fiona.tscn al nodo raíz para agregarla a la escena. Recomiendo colocarla lo suficientemente cerca de la posición inicial del jugador para facilitar las pruebas.

Creando la escena del collar

Crea una nueva escena. Elija Nodo personalizado como nodo raíz para la escena y configúrelo en un nodo Area2D . Usaremos Area2D para crear un objeto seleccionable como en el tutorial Selección y uso de elementos .

Cambie el nombre del nodo raíz a Collar y agréguele un nodo Sprite . Luego arrastre necklace.png a la propiedad Texture en el Inspector .

Agregue un nodo CollisionShape2D a Necklace y, en el Inspector , establezca su propiedad Shape en New RectangleShape2D . Luego, cambia el tamaño del rectángulo para cubrir solo el sprite del collar.

Ahora adjunte un nuevo script a Collar y guárdelo como Collar.gd en la carpeta Entidades/Collar .

Necesitamos una variable para almacenar una referencia a Fiona, así que agrega este código al script:

var fiona

func _ready():
	fiona = get_tree().root.get_node("Root/Fiona")

Luego, queremos conectar la señal body_entered() de Necklace a este script para eliminarlo del juego (como si el jugador lo hubiera cogido) y establecer la variable necklace_found de Fiona en true .

Seleccione Collar y vaya al panel Nodo . Elija la señal body_entered() y presione Conectar… (o haga doble clic en la señal). Elija Collar como el nodo para conectarse y presione el botón Conectar . Se abrirá el script Necklace.gd y se le agregará automáticamente el método que manejará la señal. Escribe este código para el método _on_Necklace_body_entered() :

func _on_Necklace_body_entered(body):
	if body.name == "Player":
		get_tree().queue_delete(self)
		fiona.necklace_found = true

Si el cuerpo que ingresó a Area2D es Player , el collar se elimina del árbol de escena y la variable necklace_found de Fiona se establece en verdadero para almacenar que se ha encontrado.

Finalmente, guarde la escena como Collar.tscn dentro de la carpeta Entidades/Collar .

Ahora, regrese a la escena principal y arrastre la escena Necklace.tscn al nodo raíz . Por ahora, recomiendo colocarlo lo suficientemente cerca de la posición de inicio del jugador para probar fácilmente los diálogos. Al final del tutorial puedes moverlo a la posición que prefieras.

Ventana emergente de diálogo

Ahora, queremos crear la ventana emergente donde tendrá lugar el diálogo con los NPC. Queremos que se vea así:

En la parte superior del panel queremos mostrar el nombre del personaje con el que estamos hablando. En la parte central mostraremos las frases del NPC mientras que en la parte inferior estarán las respuestas para elegir.

Para comenzar a crear la ventana emergente de diálogo, agregue un nodo Popup a CanvasLayer y cámbiele el nombre a DialoguePopup , luego hágalo visible para mostrarlo en el editor.

Active la ventana emergente → Propiedad exclusiva de DialoguePopup establezca la propiedad Nodo → Pausa en Procesar , para que podamos pausar el juego mientras el jugador habla con un NPC.

Agregue un nodo ColorRect a DialoguePopup . En el Inspector , establezca Rect → Position en 10, 115 y Rect → Size en 300, 55 .

Agregue un nodo Etiqueta a ColorRect y cámbiele el nombre NPCName . En el Inspector , en CustomFont cargue Font/Font.tres . Luego, establezca Colores personalizados → Color de fuente en negro. Finalmente, establezca Rect → Position en 5, 2 y Rect → Size en 290, 10 .

Agregue otra etiqueta a ColorRect y cámbiele el nombre Dialogue . En el Inspector , en CustomFont cargue Font/Font.tres . Luego, establezca Colores personalizados → Color de fuente en (128,128,128,255 ). Configure Autowrap en On y, finalmente, configure Rect → Position en 5, 15 y Rect → Size en 290, 25 .

Finalmente, agreguemos una última etiqueta a ColorRect y renómbrela Answers . En el Inspector , en CustomFont cargue Font/Font.tres . Luego, establezca Colores personalizados → Color de fuente en negro. Establezca Align to Center y, finalmente, establezca Rect → Position en 5, 43 y Rect → Size en 290, 10 .

Texto de diálogo animado

Para hacer que la ventana emergente del diálogo sea más interesante y dar una idea de la progresión de la conversación, podemos animar el texto del diálogo, haciéndolo aparecer poco a poco.

Primero, agreguemos algo de texto a las etiquetas, para que podamos ver una vista previa de la animación. Establezca las propiedades de Texto de NPCName , Diálogo y Respuestas en:

  • fiona
  • ¡Hola aventurero! Perdí mi collar, ¿puedes encontrarlo por mí?
  • [A] Sí [B] No

Ahora, agregue un nodo de tipo AnimationPlayer a DialoguePopup . El panel Animación se abrirá en la parte inferior del editor.

Haga clic en el botón Animación para crear una nueva animación y llámela ShowDialogue .

Haga clic en Add Track y agregue un Property Track . Queremos animar la etiqueta Diálogo , así que selecciónela y luego seleccione la propiedad percent_visible .

A continuación, necesitamos crear los fotogramas clave de la animación. Haga clic con el botón derecho en la pista percent_visible en la posición de 0 segundos y seleccione Insertar clave .

Se agregará un nuevo fotograma clave. Repita la operación a los 0,8 segundos para agregar un segundo fotograma clave.

Puede usar el control deslizante de zoom en la parte inferior derecha del panel para cambiar la escala de tiempo y facilitarle el trabajo. Si coloca un fotograma clave en el segundo equivocado, puede moverlo arrastrándolo a lo largo de la pista.

Ahora seleccione el fotograma clave en la posición 0 y en el Inspector establezca el Valor en 0.

Dado que queremos mostrar las respuestas solo después de que el texto de Dialogue haya aparecido por completo, agregue un Pista de propiedad también para la propiedad visible de Respuestas .

En esta nueva pista, inserte dos fotogramas clave, en las posiciones 0 y 1 segundo. Seleccione el fotograma clave en la posición 0 y en el Inspector deshabilite Valor .

Si obtiene una vista previa de la animación en el editor, obtendrá esto:

Guión emergente de diálogo

Adjunte un nuevo script a DialoguePopup y guárdelo como DialoguePopup.gd en la carpeta GUI . Esta secuencia de comandos permitirá que la secuencia de comandos de Fiona modifique las etiquetas emergentes del diálogo y manejará la entrada del usuario.

Comencemos ingresando las variables que usaremos para las etiquetas:

var npc_name setget name_set
var dialogue setget dialogue_set
var answers setget answers_set

func name_set(new_value):
	npc_name = new_value
	$ColorRect/NPCName.text = new_value

func dialogue_set(new_value):
	dialogue = new_value
	$ColorRect/Dialogue.text = new_value

func answers_set(new_value):
	answers = new_value
	$ColorRect/Answers.text = new_value

La declaración de estas variables hace uso de la palabra clave setget , utilizada para definir funciones setter/getter (en nuestro caso solo definimos la función setter). Siempre que el valor de una variable sea modificado por una fuente externa (es decir, no por el uso local en la clase), se llamará a la función setter. En nuestro caso, usamos el setter para modificar la propiedad Text de la etiqueta relativa. Getter son similares, pero se llaman cuando accedemos a una variable.

Para iniciar y finalizar los diálogos necesitaremos dos funciones. Comencemos desde la función para abrir la ventana emergente de diálogo:

func open():
	get_tree().paused = true
	popup()
	$AnimationPlayer.playback_speed = 60.0 / dialogue.length()
	$AnimationPlayer.play("ShowDialogue")

Cuando se llama, la función detiene el juego. Después de eso, llama a la función popup() para mostrar la ventana emergente de diálogo (si aún no está visible en la pantalla). Luego, la variable playback_speed de AnimationPlayer se establece para que los caracteres aparezcan en la pantalla a la misma velocidad, independientemente de la longitud del texto. Por último, la animación ShowDialogue se reproduce mediante la función play() .

La función para finalizar el diálogo simplemente reanuda el juego y oculta la ventana emergente:

func close():
	get_tree().paused = false
	hide()

En su estado predeterminado (oculto), este panel no debería recibir información. Entonces, en la función _ready() , debemos llamar a la función set_process_input() para deshabilitar el manejo de entrada:

func _ready():
	set_process_input(false)

Queremos habilitar la entrada solo cuando finalice la animación ShowDialogue . Entonces, conecte la señal animation_finished() de AnimationPlayer a DialoguePopup . La función _on_AnimationPlayer_animation_finished() se agregará automáticamente al script. Edite la función de esta manera:

func _on_AnimationPlayer_animation_finished(anim_name):
	set_process_input(true)

Para responder al NPC, necesitaremos una variable para almacenar una referencia a él, para que podamos enviarle la respuesta del jugador:

var npc

Finalmente, escribe la función que maneja la entrada del jugador:

func _input(event):
	if event is InputEventKey:
		if event.scancode == KEY_A:
			set_process_input(false)
			npc.talk("A")
		elif event.scancode == KEY_B:
			set_process_input(false)
			npc.talk("B")

Esta función comprueba que la entrada detectada es un evento de teclado ( InputEventKey ). Si se presiona una de las teclas válidas (A o B), la entrada se desactiva y la elección del jugador se envía al NPC a través de la función talk() (que escribiremos en la siguiente sección).

Guión de PNJ

Ahora que hemos completado la ventana emergente de diálogo, podemos comenzar a implementar el árbol de diálogo en el script NPC.

Abra el script Fiona.gd . Comencemos por agregarle dos variables que contendrán las referencias a DialoguePopup y Player :

var dialoguePopup
var player

func _ready():
	dialoguePopup = get_tree().root.get_node("Root/CanvasLayer/DialoguePopup")
	player = get_tree().root.get_node("Root/Player")

Dado que el jugador será recompensado con una poción, por conveniencia copie la enumeración Poción en este script:

enum Potion { HEALTH, MANA }

Ahora agregue la función talk() . Esta función permitirá al jugador iniciar una conversación y responder a los NPC:

func talk(answer = ""):
	# Set Fiona's animation to "talk"
	$AnimatedSprite.play("talk")
	
	# Set dialoguePopup npc to Fiona
	dialoguePopup.npc = self
	dialoguePopup.npc_name = "Fiona"
	
	# Show the current dialogue
	match quest_status:
		QuestStatus.NOT_STARTED:
			match dialogue_state:
				0:
					# Update dialogue tree state
					dialogue_state = 1
					# Show dialogue popup
					dialoguePopup.dialogue = "Hello adventurer! I lost my necklace, can you find it for me?"
					dialoguePopup.answers = "[A] Yes  [B] No"
					dialoguePopup.open()
				1:
					match answer:
						"A":
							# Update dialogue tree state
							dialogue_state = 2
							# Show dialogue popup
							dialoguePopup.dialogue = "Thank you!"
							dialoguePopup.answers = "[A] Bye"
							dialoguePopup.open()
						"B":
							# Update dialogue tree state
							dialogue_state = 3
							# Show dialogue popup
							dialoguePopup.dialogue = "If you change your mind, you'll find me here."
							dialoguePopup.answers = "[A] Bye"
							dialoguePopup.open()
				2:
					# Update dialogue tree state
					dialogue_state = 0
					quest_status = QuestStatus.STARTED
					# Close dialogue popup
					dialoguePopup.close()
					# Set Fiona's animation to "idle"
					$AnimatedSprite.play("idle")
				3:
					# Update dialogue tree state
					dialogue_state = 0
					# Close dialogue popup
					dialoguePopup.close()
					# Set Fiona's animation to "idle"
					$AnimatedSprite.play("idle")
		QuestStatus.STARTED:
			match dialogue_state:
				0:
					# Update dialogue tree state
					dialogue_state = 1
					# Show dialogue popup
					dialoguePopup.dialogue = "Did you find my necklace?"
					if necklace_found:
						dialoguePopup.answers = "[A] Yes  [B] No"
					else:
						dialoguePopup.answers = "[A] No"
					dialoguePopup.open()
				1:
					if necklace_found and answer == "A":
						# Update dialogue tree state
						dialogue_state = 2
						# Show dialogue popup
						dialoguePopup.dialogue = "You're my hero! Please take this potion as a sign of my gratitude!"
						dialoguePopup.answers = "[A] Thanks"
						dialoguePopup.open()
					else:
						# Update dialogue tree state
						dialogue_state = 3
						# Show dialogue popup
						dialoguePopup.dialogue = "Please, find it!"
						dialoguePopup.answers = "[A] I will!"
						dialoguePopup.open()
				2:
					# Update dialogue tree state
					dialogue_state = 0
					quest_status = QuestStatus.COMPLETED
					# Close dialogue popup
					dialoguePopup.close()
					# Set Fiona's animation to "idle"
					$AnimatedSprite.play("idle")
					# Add potion and XP to the player. 
					yield(get_tree().create_timer(0.5), "timeout") #I added a little delay in case the level advancement panel appears.
					player.add_potion(Potion.HEALTH)
					player.add_xp(50)
				3:
					# Update dialogue tree state
					dialogue_state = 0
					# Close dialogue popup
					dialoguePopup.close()
					# Set Fiona's animation to "idle"
					$AnimatedSprite.play("idle")
		QuestStatus.COMPLETED:
			match dialogue_state:
				0:
					# Update dialogue tree state
					dialogue_state = 1
					# Show dialogue popup
					dialoguePopup.dialogue = "Thanks again for your help!"
					dialoguePopup.answers = "[A] Bye"
					dialoguePopup.open()
				1:
					# Update dialogue tree state
					dialogue_state = 0
					# Close dialogue popup
					dialoguePopup.close()
					# Set Fiona's animation to "idle"
					$AnimatedSprite.play("idle")

La función hablar() :

  • reproduce la animación de conversación de Fiona al iniciar una conversación y la animación inactiva cuando finaliza la conversación.
  • configura la ventana emergente de diálogo con el nombre y la referencia de Fiona .
  • implementa los cambios de estado en el árbol de diálogo, según el estado de la misión, las respuestas del jugador y si el jugador ha encontrado el collar o no
  • mostrar la línea actual de diálogo en la ventana emergente de diálogo.
  • recompensa al jugador cuando se completa la misión.

Esta función hace un uso intensivo de la declaración de coincidencia . La declaración de coincidencia se utiliza para bifurcar la ejecución de un programa. Es el equivalente de la declaración de cambio que se encuentra en muchos otros idiomas. Tiene la siguiente sintaxis:

match [expression]:
    [pattern](s):
        [block]
    [pattern](s):
        [block]
    [pattern](s):
        [block]

La expresión después de la palabra clave de concordancia se compara con los patrones de las siguientes líneas. Si un patrón coincide, se ejecutará el bloque correspondiente. Después de eso, la ejecución continúa debajo de la declaración de coincidencia .

Guión del jugador

Lo último que debe hacer es insertar en el script del jugador el código para iniciar una conversación cuando presiona la tecla de ataque (la barra espaciadora o la tecla relativa del joypad). Entonces, en la función _input() , edite el código que maneja el evento de entrada de ataque de esta manera:

if event.is_action_pressed("attack"):
	# Check if player can attack
	var now = OS.get_ticks_msec()
	if now >= next_attack_time:
		# What's the target?
		var target = $RayCast2D.get_collider()
		if target != null:
			if target.name.find("Skeleton") >= 0:
				# Skeleton hit!
				target.hit(attack_damage)
			# NEW CODE - START
			if target.is_in_group("NPCs"):
				# Talk to NPC
				target.talk()
				return
			# NEW CODE - END
		# Play attack animation
		attack_playing = true
		var animation = get_animation_direction(last_direction) + "_attack"
		$Sprite.play(animation)
		# Add cooldown time to current time
		next_attack_time = now + attack_cooldown_time

Si el objetivo del «ataque» es parte del grupo de NPC , se llama a su función talk() para iniciar el diálogo.

Prueba de búsqueda y diálogos.

Intente ejecutar el juego y verifique que todas las opciones de diálogo funcionen correctamente.

Cuando hayas comprobado que todo funciona, mueve el collar a su posición final (recomiendo colocarlo al otro lado del mapa).

Conclusiones

El sistema de búsqueda y diálogo que hemos creado para nuestro juego es un buen punto de partida para comprender cómo funciona dicho sistema. Sin embargo, hay muchas cosas que se pueden mejorar. Por ejemplo:

  • Todos los diálogos están incrustados en el script NPC . Es deseable que los diálogos se escriban en un archivo externo fácil de editar y legible por humanos que se cargue en Runtime.
  • El código de búsqueda está dentro del NPC . El estado de una misión puede depender de varios NPC y muchos otros factores, por lo que es preferible tener algún tipo de sistema «centralizado» que gestione las misiones.
  • El NPC es estático y solo reacciona a la entrada del jugador . En muchos juegos, los NPC tienen su propia rutina de movimientos y acciones, lo que requiere implementar un sistema de secuencias de comandos más complejo.
  • Actualmente, los monstruos ignoran a los NPC y viceversa .

Además de haber aprendido a implementar el sistema de búsquedas y diálogos, en este tutorial aprendimos algunas otras cosas nuevas:

  • cómo animar el texto de Label de una manera interesante;
  • qué son los grupos y cómo usarlos;
  • qué son setters/getters;
  • cómo usar la declaración de coincidencia.