, , , , ,

Programa un NPC desde 0

by

Plataforma: Unity

Lenguaje de programación: C#

Dificultad: Principiante

¡Feliz Sábado! Hoy, por fin, vamos a utilizar lo que aprendimos de la Inteligencia Artificial y las Máquinas de Estado Finitas para crear un NPC (Personaje no Jugador en inglés) básico en Unity, un personajillo que andará por un pueblito medieval.

¿Te apuntas?

Recuerda que si prefieres seguir un vídeo, puedes verlo en mi canal:

Índice

¿Qué necesitarás?

  • Aunque no es necesario para seguir el tutorial, te recomiendo leerte este artículo para entender las bases de lo que vamos a hacer.
  • Unity. En este artículo utilizo Unity 2021.1.12f1, pero cualquiera debería valer.
  • Conocimientos de C#.

Preparando el escenario

El escenario 3D preparado para este tutorial, representando un pequeño pueblo medieval.
Un pueblito medieval en Unity

He creado este pequeño nivel para nuestros experimentos de inteligencia artificial. Los modelos pertenecen a Quaternius, que los deja disponibles en su web sin licencia y totalmente gratuitos.

Si te gustan y quieres utilizarlos en tus videojuegos, estos son los packs que he utilizado:

Preparando el PNJ

El modelo lowpoly de un PNJ que representa a una exploradora.
Nuestro PNJ: La Exploradora

No sé si lo sabes, pero soy muy fan de Sylvanas Brisaveloz. He sido leal a la Reina Alma en Pena desde que supe sobre ella, y al coger este pack de personajes no podía dejar pasar la oportunidad de coger a su hermana pequeña para el proyecto.

Para preparar a nuestra poligonada exploradora para esta aventura sólo hay que tomar unos pocos pasos. Estos te deberían servir para cualquier PNJ humanoide que quieras crear.

  1. Convertir su esqueleto a Humanoide en la ventana de importación. Lo encontrarás en el inspector tras seleccionar su modelo en el proyecto.
Unity - Menú Rig dentro de Import Settings, en el inspector tras seleccionar un modelo.
  1. Sacarla a la escena, unpackearla, crearle los materiales y escalarla para que tenga sentido con el resto de modelos. En mi caso, cambié la escala de CharacterArmature y Ranger de 100u a 25u en todos los ejes.
Muestra de que la escala de un modelo importado suele ser de (100, 100,100) en el inspector, tras sacarlo a la escena.
  1. Crear un objeto vacío con punto de origen en el suelo (Y = 0) y meter el modelo dentro, al que llamé Root. De esta forma separaremos la lógica de los gráficos, y tendremos todo a escala para el resto de objetos.
  1. Bajarse unas animaciones gratuitas de Idle y Walk. Yo me las bajo de Mixamo.com. Las metemos en el proyecto, duplicamos las animaciones y les cambiamos el rig a Humanoide, como hicimos con el modelo.
  2. Armar un AnimatorController con esas animaciones y la clave float Speed para las transiciones entre ellas. Le pondremos este animador a la raíz de los gráficos del personaje.
El animador de nuestro PNJ en Unity.
  1. Guardar ese objeto raíz como el prefab de la exploradora, Ranger en inglés.
  2. ¡Fin!

La Inteligencia Artificial

Vamos a hacer una inteligencia súper sencillita para este personaje, la más básica que puede haber. Nuestra exploradora caminará por una serie de puntos, en orden, esperando un número predeterminado de segundos en cada uno de ellos.

Para este tutorial no vamos a hacer cuadrículas de navegación ni algoritmos de búsqueda de caminos, pero si ya tienes estos sistemas en tu proyecto, ¡no te preocupes! Vas a ver que son muy fáciles de incluir.

Los cimientos

Como para todo lo bueno en esta vida, necesitamos sentar unas bases sólidas que nos permitan construir sin miedo a que se caiga el «edificio».

En nuestra jerarquía de proyecto crearemos tres scripts:

  • Brain, el cerebro de la operación, representa al PNJ y es la máquina de estados finita.
  • State, la representación de cada uno de esos estados.
  • Behaviour, las acciones en sí de los personajes.

A mí me gusta ponerles nombres relevantes a lo que estoy haciendo y, ¿por qué no? Divertidos si es posible 😜 Pero si te gustan los nombres más literales, representando a la estructura de datos que implementan, cambia Brain por Fsm, State por FsmState y Behaviour puedes dejarlo igual, o añadirle Fsm por delante.

Behaviour

Es la mínima expresión de este sistema, así que vamos a empezar por ella.

Va a ser una clase abstracta que heredaremos por cada uno de los comportamientos y acciones que tomen los personajes, así que tendrá poco código por ahora.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


namespace BuscaLaExcusa.AI
{
	public abstract class Behaviour : MonoBehaviour
	{
		protected Brain _npc = null;
		public bool Finished { get; private set; } = false;

		public Behaviour(Brain npc)
		{
			_npc = npc;
		}

		public virtual void Start()
		{
			Finished = false;
		}

		public abstract void Update();
		protected virtual void End()
		{
			Finished = true;
		}

	}
} 

Necesitará una referencia al cerebro, Brain, para poder obtener datos del estado actual del personaje. Puede parecer una tontería, pero muchísimos comportamientos necesitan saber cosas como: ¿está esta otra cosa en rango?, ¿estoy cayendo?, ¿qué velocidad tenemos para andar? y un largo etcétera. Todo esa información sólo podremos obtenerla preguntándole directamente a la representación física del personaje, _npc (PNJ en inglés), así que debemos obtenerla en el momento de la instanciación de la clase.

El FSM (Máquina de Estados Finita en inglés) necesitará saber si este comportamiento ha terminado, porque algunas transiciones dependerán de ello, así que añadiremos un booleano llamado Finished.

¿Qué es public bool Finished {get; private set; } = false; ? ¿Por qué está escrito así?
Cuando queremos que una variable no se pueda modificar desde fuera de la clase, podemos asignarle una función get para devolvernos el valor y una función set para cambiárselo.
C# nos permite crear estas funciones del todo, pero también nos permite decirle que utilice las suyas y les cambie los permisos de acceso.
public bool Finished { get; private set; } significa «déjame recuperar el valor de Finished a través de su get con la misma libertad que tenga la variable (que es pública)» y «sólo esta clase puede cambiar el valor de la variable Finished, a través de su set, que es privado».
Al final del todo incluimos = false para declarar el valor inicial de la variable.

Start, Update y End serán nuestras funciones estrella; la columna vertebral del flujo de esta clase.

  • Implementaremos Start como una función virtual que deja a Finished en falso. Esta pequeña instrucción será vital para poder reutilizar los estados del FSM. Verás por qué dentro de poco.
  • Dejaremos Update como una función abstracta.
  • Implementaremos End como una función virtual que deja a Finished en verdadero.

State

La siguiente pieza clave que vamos a tratar en esta aventura es el Estado del FSM.

¿Recuerdas esta imagen?

Cada uno de esos círculos representa un estado de la máquina, un punto en el que puede estar. Cada uno de ellos ejecuta una acción (que antes hemos llamado Behaviour), como por ejemplo: Walk, Idle, Talk, Door, Run o Sleep. Además, también puede elegir transicionar a otros estados a través de las flechas que salen de ellos, ¡e incluso pueden ir a sí mismos!

Puede que te hayas fijado en que no sólo he separado en concepto Estado de Acción, sino que también lo he hecho con las clases Behaviour y State, respectivamente. Esto es porque la experiencia me ha enseñado que mezclar estas dos estructuras hará que todo se vuelva más complicado a la larga.

Ya que State es un estado en sí, necesitaremos otra estructura de datos que represente a las transiciones de la máquina, las flechitas de la imagen. Como se trata de una pieza de datos que no va a cambiar nunca, utilizaremos un struct al que llamaremos Transition.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace BuscaLaExcusa.AI
{
	public struct Transition
	{
		public Func<bool> Condition;
		public State NextState;

		public Transition (Func<bool> condition, State nextState)
		{
			Condition = condition;
			NextState = nextState;
		}
	}

	public class State
	{
	}
}

Los structs son estructuras que el compilador considerará inmutables, es decir, no espera que cambien de estado o al menos no a menudo, a diferencia de las clases que lo hacen constantemente. Aunque podamos cambiar los datos de un struct en cualquier momento, es mejor no hacerlo.

Necesitaremos una Func<bool> para representar la condición en sí. Func es una clase especial de C# que nos permite guardar en ella cualquier función que cumpla con unos criterios que definiremos al instanciarla. En este caso, <bool> significa «debe devolver un valor booleano».

Por último, necesitaremos una referencia al estado al que queramos movernos después, un State.

State, por su parte, necesitará una lista de Transition y el Behaviour que ejecutará en cada frame, y un constructor que los inicialice correctamente.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace BuscaLaExcusa.AI
{
	public struct Transition
	{
		public Func<bool> Condition;
		public State NextState;

		public Transition (Func<bool> condition, State nextState)
		{
			Condition = condition;
			NextState = nextState;
		}
	}

	public class State
	{
		private List<Transition> _transitions;
		public Behaviour Behaviour;

		public State(Behaviour behaviour)
		{
			Behaviour = behaviour;
			_transitions = new List<Transition>();
		}
	}
}

Como Behaviour es una clase abstracta, State en realidad guardará una de sus clases hija, que habrá heredado y sobrescrito las funciones que creamos antes para Behaviour. De esta manera, podemos asegurarnos que todas las clases van a comportarse igual. State nunca debería preocuparse de a qué Behaviour está llamando, sólo de estarlo haciendo.

No hemos añadido transiciones en el constructor porque sería la pescadilla que se muerde la cola.

  • Para crear una Transición necesitamos un Estado.
  • Para crear un Estado, necesitamos una lista de Transiciones.

¿Qué va primero, la transición o el estado de la máquina?

Así que vamos a dotarle de una función que nos permita añadir transiciones más tarde:

public void AddTransition(Transition newTransition)
{
	_transitions.Add(newTransition);
}

Por último, la máquina de estados, el FSM, va a necesitar saber si hay alguna transición «abierta». Cuando digo que una transición está abierta, lo que quiero decir es que se puede pasar por ella, su condición es true y nos permite adquirir un nuevo estado.

Como podría darse el caso de que una máquina tuviera más de una transición abierta, vamos a considerar que la primera que encontremos es la transición más deseable. Deberemos tener esto en cuenta a la hora de crear nuestras máquinas, ya que las transiciones más importantes deberían aparecer primero para que sean favorecidas por el algoritmo:

public State CanTransition()
{
	int i = 0;
	while (i < _transitions.Count && !_transitions[i].Condition())
	{
		++i;
	}

	return i < _transitions.Count ? _transitions[i].NextState : null;
}

Y devolvemos State a quien nos llame. Un State sólo se conoce a sí mismo; nunca debería tener poder de cambiar a otro State.

Brain

¡Por fin! La máquina de estados en sí, la que va a ejecutar todo lo que hemos hecho hasta ahora. La buena noticia es que en realidad, ¡ya hemos hecho más de la mitad del trabajo! Sólo nos queda unir los puntos.

Como Behaviour, Brain va a ser una clase abstracta para darnos la mayor flexibilidad posible. Haremos una clase por cada tipo de PNJ que queramos hacer, y heredaremos de ella para crear las máquinas.


namespace BuscaLaExcusa.AI
{
	public abstract class Brain : MonoBehaviour
	{
		[SerializeField] protected string _id = "";
		protected State _currentState;
	}
}

Necesitaremos darle un ID a los PNJs, algo que los diferencie del resto. Esto será útil para saber con quién estamos hablando, por ejemplo. También añadiremos una referencia al estado actual de la máquina.

Para terminar, necesitaremos definir *qué* hace esta clase. Como sabemos, se trata de un FSM; una Máquina de Estados Finita, y tenemos un estado que ejecutar, _currentState, así que…

private void Awake()
{
	_currentState = CreateBehaviour();
	_currentState.Behaviour.Start();
}

protected abstract State CreateBehaviour();

Añadiremos una función Awake, la cuál se ejecutará nada más entre este objeto en la escena, que instanciará _currentState con el valor devuelto por una función abstracta que también crearemos, llamada CreateBehaviour.

Al ser abstracta, no tenemos que escribir qué pasa dentro de ella, de eso se encargarán las clases que hereden de ésta. Lo único que importa es que siempre tiene que devolvernos el primer estado de la máquina.

Ahora, para asegurarnos de que nuestra máquina es más que un bonito pisapapeles bytes, añadiremos una función Update tal que así:

private void Update()
{
	State newState = _currentState.CanTransition();
	if (newState != null)
	{
		_currentState = newState;
		_currentState.Behaviour.Start();
	}

	_currentState.Behaviour.Update();
}

Primero, comprobaremos si hay alguna transición abierta. State nos devolverá un estado nuevo, si es que se podía transicionar a él, o null si no ha encontrado ninguno.

De encontrar uno nuevo, lo guardaremos como nuestro estado actual y le diremos que haga su Start.

Después, hayamos o no hayamos encontrado un estado nuevo, le diremos al estado actual que se actualice.

Y así es como hacemos una Máquina de Estados Finita 😉

Espera, espera, espera… ¡Esta máquina no puede terminar!

En realidad, el concepto de «terminar» es muy muy amplio.

Hay tres formas en las que podría terminar esta máquina:

  • Llega a un estado nulo.
  • Llega a un estado cuyo Behaviour es nulo.
  • Llega a un estado cuyo Behaviour no hace nada por dentro, y no tiene más transiciones.

La forma en la que he diseñado este FSM es que acaba cuando se da la tercera opción; llega a un estado del que no puede salir, y en el que no está haciendo nada realmente.

Elijo hacerlo así porque la realidad también es que casi ninguna de mis IAs llegará a un estado final, en realidad. De hacerlo, probablemente sería un estado «Muerto» del que me interesará que salgan si voy a reutilizarlos.

¡Pero siéntete libre de crear tus propias soluciones! Hay un número infinito de formas en las que podrías decidir que tu máquina termina de operar, ¡y todas serán excelentes! Cuéntamelas en los comentarios ❤

Creando a la Exploradora

Nuestra exploradora tiene una misión muy importante; patrullar el poblado. Durante su patrulla, además, debería quedarse un tiempo determinado mirando, vigilando.

Su máquina de estados sería algo así:

Analizando esta máquina, llegamos a la conclusión de que sólo nos hacen falta dos Behaviour; Andar y Esperar. Walk y Wait.

Así que empecemos por ellos.

Wait

Uno de los comportamientos más sencillos que puede tener un personaje. Todo lo que tiene que hacer es esperar a que acabe un temporizador externo.

Para crearlo, necesitaremos una clase nueva llamada Wait que herede de Behaviour. Recuerda que debes escribir también su constructor y todas las funciones abstractas que no hubiéramos implementado en Behaviour.

namespace BuscaLaExcusa.AI.Behaviours
{
	public class Wait : Behaviour
	{
		public Wait(Brain npc) : base (npc)
		{
		}

		public override void Update()
		{
		}
	}
}

Wait depende en exclusiva de tener un temporizador interno, y saber cuántos segundos tiene que estar ejecutándose, así que tendremos que guardarlo en algún sitio:


namespace BuscaLaExcusa.AI.Behaviours
{
	public class Wait : Behaviour
	{
		protected float _seconds;
		protected float _timer;

		public Wait(Brain npc, float seconds) : base (npc)
		{
			_seconds = seconds;
		}

		public override void Start()
		{
			base.Start();
			_timer = _seconds;
		}

		public override void Update()
		{
		}
	}
}

_seconds guardará los segundos que debe esperar este comportamiento, mientras que _timer será la variable que hará de temporizador. Fíjate en que he añadido también la función Start, que antes no estaba, para asignarle a _timer los segundos. Este es un paso muy importante, ya que si volvemos sobre este estado más adelante en la máquina, cuando el ciclo de patrulla se reinicie, podríamos encontrarnos el temporizador vacío.

Nos falta ahora gestionar ese tiempo

public override void Update()
{
	if (Finished)
	{
		return;
	}

	_timer -= Time.deltaTime;
	if (_timer >= 0f)
	{
		return;
	}

	End();
}

En el Update nos encargaremos de decrecer el temporizador hasta 0f, momento en el que determinaremos que el comportamiento ha terminado llamando a End. Si el comportamiento ya ha terminado, no haremos nada.

Time.deltaTime es un valor dado por Unity que representa el tiempo que ha pasado entre la presentación de un frame y el siguiente. Su unidad son los segundos, por lo que tiene muchísimos usos. Por ejemplo, en este caso, estamos utilizándolo para contar hacia atrás, ya que sabemos que si la suma de muchos Time.deltaTime es 1, eso significa que ha pasado 1 segundo.

Con esto, Wait está terminado.

Walk

Caminar es una acción bastante más compleja. Como mi objetivo es que aprendas a crear tus propias máquinas, no vamos a meternos en navgrids ni algoritmos de búsquedas de caminos. Vamos a mantenerlo súper simple: la Exploradora debe ser capaz de ir en línea recta del punto A al punto B.

Como siempre, empezamos creando la clase:

namespace BuscaLaExcusa.AI.Behaviours
{
	public class Walk : Behaviour
	{
		public Walk(Brain npc) : base(npc)
		{
		}

		public override void Update()
		{
		}

	}
}

Para ir del punto A al punto B necesitamos saber cuáles son estos puntos. Podemos asumir que el punto de partida es la posición actual del personaje, de manera que sólo necesitaríamos saber hacia dónde tiene que ir. No menos importante, también necesitaremos saber a qué velocidad lo hace.

namespace BuscaLaExcusa.AI.Behaviours
{
	public class Walk : Behaviour
	{
		protected Vector3 _target;
		protected Vector3 _direction;
		protected float _speed;

		public Walk(Brain npc, Vector3 point, float speed) : base(npc)
		{
			_target = point;
			_speed = speed;
		}

		public override void Start()
		{
			base.Start();
			_direction = (_target - _npc.transform.position).normalized;
		}

	}
}

Gracias a que tenemos la referencia del Brain que maneja esta máquina, podemos acceder a su posición en el mundo. Te dije que sería útil 😉

Ya sólo nos queda lo más importante: el movimiento en sí.

La clave de esto va a ser que el movimiento lo haremos sobre el objeto raíz, donde debería ubicarse Brain, y la rotación, es decir, hacia dónde mira el personaje, lo haremos sobre el modelo gráfico. De esta manera siempre tendremos un objeto que representa dónde se encuentra, sin más transformaciones que esa, y será útil para ciertos comportamientos lógicos asociados que podrían colisionar (no responder bien) a ser rotados. Y todo lo que queremos que rote será, en la mayor parte de los casos, parte del personaje en sí.

Ahora, tenemos dos formas de hacer esto:

  • Walk se encarga.
  • Brain se encarga.

Párate un momento a pensar los motivos a favor y en contra de cada una de estas opciones.

¿Lo has pensado? ¡Estupendo!

Mi sugerencia es que sea Brain quien se encargue. Por un lado, tendría sentido que fuera Walk quien se ocupara; es el único comportamiento que necesita mover al personaje. Sin embargo, ¿qué pasaría si necesitáramos otro comportamiento que necesitara moverlo? Como, por ejemplo, Run, correr. Si fuera Walk quien se encargara, tendríamos que duplicar este código para cada instancia que necesitara mover al personaje.

En vez de eso, podemos dejar que sea Brain quien lo haga. Así nos aseguraremos de que siempre sea consistente en todos los escenarios, además de abrirnos las puertas a cambiar todo el movimiento si lo necesitáramos más adelante. Y créeme, hay variantes muy interesantes de hacer esto.

Por tanto, Brain tendrá tres variables y tres funciones nuevas:


[SerializeField] protected Transform _graphics = null;
[SerializeField] protected Animator _animator = null;
[SerializeField] protected string _speedKey = "Speed";

...

public void SetSpeed(float speed)
{
	_animator.SetFloat(_speedKey, speed);
}

public void Move(Vector3 direction, float speed)
{
	transform.position += direction * speed * Time.deltaTime;
	_graphics.forward = direction;
	SetSpeed(speed);
}
		
public void TeleportTo(Vector3 position)
{
	transform.position = position;
	SetSpeed(0f);
}

Por un lado, SetSpeed nos permitirá comunicarle al Animator el valor de la variable que permite activar la animación de andar. Puedes comentar esta parte si todavía no has configurado uno.

Move tomará una dirección y una velocidad, las multiplicará junto con Time.deltaTime, y se la sumará a la posición del transform de Brain. Además, le dirá a los gráficos que su nuevo vector forward (hacia dónde mira el modelo) es la dirección en la que se mueve. Por último, actualiza el animador.

Me he adelantado un poco y he añadido TeleportTo. Esta función moverá el personaje directamente a un punto y pondrá su velocidad a cero. La necesitaremos para cuando terminemos Walk, en seguida verás por qué.

Time.deltaTime vuelve a aparecer aquí para una utilidad diferente. Esta vez, en vez de usarlo para contar cuánto tiempo ha pasado, lo utilizamos para descubrir cuánto debería haberse movido el objeto desde que se presentó el frame anterior.

Con estas herramientas en la mano, ya podemos volver a Walk y terminar el Update:


public override void Update()
{
	if (Finished)
	{
		return;
	}

	_npc.Move(_direction, _speed);

	float distance = GetDistanceToTarget();

	if (distance >= _closestDistance)
	{
		End();
		return;
	}

	if (distance < _closestDistance)
	{
		_closestDistance = distance;
	}

	if (Mathf.Approximately(_closestDistance, 0f))
	{
		End();
	}
}

El esquema de Update es muy sencillo:

  1. Si ya hemos terminado de andar, no hacemos nada.
  2. Visto que no hemos terminado, nos movemos en la dirección y con la velocidad deseadas.
  3. Ahora necesitamos saber si ya hemos llegado, para ello…
    1. Obtenemos la distancia hasta el objetivo, que estará al cuadrado.
    2. Comprobamos que la distancia siga siendo menor que la última vez, esto nos dirá que nos hemos acercado y no alejado.
    3. Si fuera más grande, nos habríamos pasado. En ese caso, llamaremos a End.
  4. Actualizamos con la distancia más pequeña.
  5. Y si por alguna razón, esa distancia más pequeña es aproximadamente 0, llamamos a End también para terminar.

Para poder hacer esto, necesitaremos la nueva variable _closestDistance y la función GetDistanceToTarget. Tendremos que iniciarlizarla llamando a esta función en el Start, así:

...
protected float _closestDistance;

public override void Start()
{
	...
	_closestDistance = GetDistanceToTarget() + 0.001f;
}

private float GetDistanceToTarget()
{
	return (_target - _npc.transform.position).sqrMagnitude;
}

Y por último, debemos asegurarnos de que el personaje queda justo encima del punto al que queríamos mandarle, ni más alante ni más atrás. Bastará con llamar a TeleportTo en nuestra función End:

protected override void End()
{
	if (Finished)
	{
		return;
	}

	_npc.TeleportTo(_target);
	base.End();
}

¡Hora de montarlo todo!

Es hora de crear la clase… RangerNpc.

Y te prometo que te va a sorprender lo corta que es.

namespace BuscaLaExcusa.AI
{
	public class RangerNpc : Brain
	{
		[SerializeField] protected float _walkingSpeed = 1f;
		[SerializeField] protected List<Transform> _points = new List<Transform>();

		protected override State CreateBehaviour()
		{			
			???
		}
	}
}

Eso es todo. Sí, sí, te lo prometo.

Vale, es cierto que hay ahí tres interrogaciones que no te dicen nada, ¿pero de verdad pensabas que no te lo iba a explicar todo? Vamos a ello.

Lo primero es que RangerNpc es una hija que hereda de Brain, y Brain prácticamente ya lo tiene todo. Lo que pasa es que es abstracta, y necesita que sus hijas le expliquen qué hacer con las piezas de información que le faltan.

La pieza de información clave que falta aquí es la máquina de estados en sí, el FSM, que empieza por un sólo State. Esto es lo que significa ??? y lo que nos queda por definir.

Como habrás visto, hemos definido una velocidad para andar, y una lista de puntos a través de los cuáles tiene que pasar. Estos deberás proveérselos a través del inspector, así:

Digamos que queremos que espere un poco y después que ande hasta el primer punto. Primero necesitamos los estados:

protected override State CreateBehaviour()
{
	State wait1 = new State(new Wait(this, 1f));
	State walkToP1 = new State(new Walk(this, _points[0].position, _walkingSpeed));
	
}

Ahora necesitamos añadir las transiciones. Como recordarás, éstas necesitan una función que devuelva true o false. En el caso de la Exploradora, para cambiar de estados todo lo que necesitará será saber que la acción del estado anterior terminó. Conseguiremos esto añadiendo la siguiente función a Brain:


protected bool LastStateFinished()
{
	return _currentState.Behaviour.Finished;
}

Y ahora añadimos las transiciones. Como lo hemos hecho todo rematadamente fácil, el código sería éste:

protected override State CreateBehaviour()
{
	State wait1 = new State(new Wait(this, 1f));
	State walkToP1 = new State(new Walk(this, _points[0].position, _walkingSpeed));

	wait1.AddTransition(new Transition(LastStateFinished, walkToP1));
	walkToP1.AddTransition(new Transition(LastStateFinished, wait1));

	return wait1;
}

Vamos a desglosarlo:

  1. Creamos los comportamientos que va a tener este personaje. En concreto, queremos que espere un poquito (wait1) y después camine hasta el primer punto (walkToP1).
  2. Ahora, añadimos las transiciones para que ocurra de manera cíclica. Primero, de wait1 a walkToP1, y después al revés.
  3. Devolvemos wait1 porque es el primer estado en el que queremos que entre.

Y ya está 🙂 Esto es lo que tendrás que hacer a partir de ahora para montar una Inteligencia Artificial nueva, desde 0, en tu videojuego. Heredar de Brain, programar la máquina, y hacer click en Play.

Vamos a ver cómo sería con los cuatro puntos:

protected override State CreateBehaviour()
{
	State wait1 = new State(new Wait(this, 1f));
	State walkToP1 = new State(new Walk(this, _points[0].position, _walkingSpeed));
			
	State wait2 = new State(new Wait(this, 2f));
	State walkToP2 = new State(new Walk(this, _points[1].position, _walkingSpeed));
			
	State wait3 = new State(new Wait(this, 3f));
	State walkToP3 = new State(new Walk(this, _points[2].position, _walkingSpeed));
			
	State wait4 = new State(new Wait(this, 4f));
	State walkToP4 = new State(new Walk(this, _points[3].position, _walkingSpeed));

	wait1.AddTransition(new Transition(LastStateFinished, walkToP1));
	walkToP1.AddTransition(new Transition(LastStateFinished, wait2));

	wait2.AddTransition(new Transition(LastStateFinished, walkToP2));
	walkToP2.AddTransition(new Transition(LastStateFinished, wait3));

	wait3.AddTransition(new Transition(LastStateFinished, walkToP3));
	walkToP3.AddTransition(new Transition(LastStateFinished, wait4));

	wait4.AddTransition(new Transition(LastStateFinished, walkToP4));
	walkToP4.AddTransition(new Transition(LastStateFinished, wait1));

	return wait1;
}

¿Ves que hemos hecho exactamente lo mismo?

  1. Hemos creado los estados.
  2. Hemos unido los estados.
  3. Hemos devuelto el estado por el que queremos que empiece.

Es cierto que este código no es el más bonito del mundo, y te aseguro que hay formas de que quede mucho mejor. Para empezar, ¿qué ocurre si no tenemos cuatro puntos para que recorra este personaje? ¿O si queremos que tenga más? Escribir todo esto cada vez no es muy eficiente. ¿Se te ocurre alguna manera de automatizarlo? Déjalo en los comentarios 😉

Conclusiones

  • Hacer una inteligencia artificial es en realidad muy sencillo, siempre que tengas los fundamentos teóricos básicos para construirla.
  • La forma más sencilla, manejable y escalable (al menos, que yo conozco) es construir una Máquina de Estados Finita (MEF) o Finite State Machine (FSM).
  • Hemos logrado esto creando una infraestructura con las clases Behaviour, State y Brain, que contienen todo el flujo básico de nuestros futuros NPCs.
  • Además, hemos creado a la Exploradora para proteger nuestro poblado. Lo hemos conseguido creando dos Behaviours nuevos; Wait y Walk, y una clase que represente a la exploradora, RangerNpc.
  • Dentro de RangerNpc hemos dado forma a lo que su comportamiento debería ser.

¡Felicidades!

¿Y ahora qué?

¡Ahora a crear! ¿Cuántos comportamientos nuevos puedes darle a tu personaje? ¡Infinitos!

En Busca la Excusa no dejamos de buscar cualquier pretexto para seguir aprendiendo. Si te interesan los tutoriales de inteligencia artificial, te sugiero los siguientes artículos de mi blog:

Nos vemos en la próxima excusa 😉

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *