Simple zombie shooter en Unity

Hola a todos! Pronto, las clases comenzarán en el primer grupo del curso Unity Games Developer . En previsión del comienzo del curso, se llevó a cabo una lección abierta sobre la creación de un zombie shooter en Unity. El seminario web fue organizado por Nikolai Zapolnov , desarrollador sénior de juegos de Rovio Entertainment Corporation. También escribió un artículo detallado, que llamamos su atención.



En este artículo, me gustaría mostrar lo fácil que es crear juegos en Unity. Si tiene conocimientos básicos de programación, puede comenzar a trabajar rápidamente con este motor y crear su primer juego.



Descargo de responsabilidad # 1: Este artículo es para principiantes. Si te comiste un perro en Unity, entonces te puede parecer aburrido.

Descargo de responsabilidad # 2: Para leer este artículo, necesita al menos conocimientos básicos de programación. Como mínimo, las palabras "clase" y "método" no deberían asustarlo.

¡Atención, tráfico bajo el corte!

Introducción a la unidad


Si ya está familiarizado con el editor de Unity, puede omitir la introducción e ir directamente a la sección "Creación de un mundo de juegos".

La unidad estructural básica en Unity es la "escena". Una escena suele ser un nivel del juego, aunque en algunos casos puede haber varios niveles a la vez en una escena o, por el contrario, un gran nivel puede dividirse en varias escenas cargadas dinámicamente. Las escenas están llenas de objetos del juego y, a su vez, están llenas de componentes. Son los componentes los que implementan diversas funciones del juego: dibujar objetos, animación, física, etc. Este modelo le permite ensamblar la funcionalidad a partir de bloques simples, como un juguete del constructor de Lego.

Puede escribir componentes usted mismo, utilizando el lenguaje de programación C # para esto. Así es como se escribe la lógica del juego. A continuación veremos cómo se hace esto, pero por ahora echemos un vistazo al motor en sí.

Cuando arranque el motor y cree un nuevo proyecto, verá una ventana frente a usted donde puede seleccionar cuatro elementos principales:



En la esquina superior izquierda de la captura de pantalla se encuentra la ventana "Jerarquía". Aquí podemos ver la jerarquía de los objetos del juego en la escena abierta actual. Unity creó dos objetos del juego para nosotros: una cámara ("Cámara principal") a través de la cual el jugador verá nuestro mundo del juego y una "Luz direccional" que iluminará nuestra escena. Sin él, solo veríamos un cuadrado negro.

En el centro está la ventana de edición de escena ("Escena"). Aquí vemos nuestro nivel y podemos editarlo visualmente: mover y rotar objetos con el mouse y ver qué sucede. Cerca puedes ver la pestaña "Juego", que actualmente está inactiva; si cambias a él, puedes ver cómo se ve el juego desde la cámara. Y si comienzas el juego (usando el botón con el ícono de jugar en la barra de herramientas), Unity cambiará a esta pestaña, donde jugaremos el juego lanzado.

En la parte superior derecha está la ventana "Inspector". En esta ventana, Unity muestra los parámetros del objeto seleccionado y podemos editarlos. En particular, podemos ver que la cámara seleccionada tiene dos componentes: "Transformar", que establece la posición de la cámara en el mundo del juego, y, de hecho, "Cámara", que implementa la funcionalidad de la cámara.

Por cierto, el componente Transformar está en una forma u otra en todos los objetos del juego en Unity.

Y finalmente, en la parte inferior está la pestaña "Proyecto", donde podemos ver todos los llamados activos que están en nuestro proyecto. Los activos son archivos de datos como texturas, sprites, modelos 3D, animaciones, sonidos y música, archivos de configuración. Es decir, cualquier dato que podamos usar para crear niveles o la interfaz de usuario. Unity comprende una gran cantidad de formatos estándar (por ejemplo, png y jpg para imágenes, o fbx para modelos 3D), por lo que no habrá problemas para cargar datos en un proyecto. Y si usted, como yo, no sabe cómo dibujar, los activos se pueden descargar de la Unidad de activos de Unity, que contiene una gran colección de todo tipo de recursos: tanto gratuitos como vendidos por dinero.

A la derecha de la pestaña "Proyecto", la pestaña inactiva "Consola" está visible. Unity escribe advertencias y mensajes de error en la consola, así que asegúrese de revisar periódicamente. Especialmente si algo no funciona, lo más probable es que la consola indique la causa del problema. Además, la consola puede mostrar mensajes del código del juego, para la depuración.

Crea un mundo de juego


Como soy programador y dibujo peor que la pata de pollo, para los gráficos tomé algunos activos gratuitos de la Unidad de activos de Unity. Puede encontrar enlaces a ellos al final de este artículo.

De estos activos, reuní un nivel simple con el que trabajaremos:



Sin magia, simplemente arrastré los objetos que me gustaron desde la ventana del Proyecto y con el mouse los arreglé como me gusta:



Por cierto, Unity le permite agregar objetos estándar a la escena con un solo clic, como un cubo, esfera o plano. Para hacer esto, simplemente haga clic derecho en la ventana Jerarquía y seleccione, por ejemplo, 3D Object⇨Plane. Entonces, el asfalto en mi nivel simplemente se ensambla a partir de un conjunto de planos sobre el que "extraje" una textura de un conjunto de activos.

Nota: si se pregunta por qué usé muchos planos, y no uno con valores a gran escala, la respuesta es bastante simple: un plano con una escala grande tendrá una textura muy ampliada, que se verá poco natural con respecto a otros objetos en la escena (esto se puede arreglar con los parámetros material, pero estamos tratando de hacer todo lo más simple posible, ¿verdad?)

Zombis en busca de un camino


Entonces, tenemos un nivel de juego, pero todavía no está sucediendo nada. En nuestro juego, los zombis perseguirán al jugador y lo atacarán, y para ello deben poder moverse hacia el jugador y sortear obstáculos.

Para implementar esto, usaremos la herramienta "Malla de navegación". En función de los datos de la escena, esta herramienta calcula las áreas donde puede moverse y genera un conjunto de datos que se pueden utilizar para buscar la ruta óptima desde cualquier punto del nivel a cualquier otro durante el juego. Estos datos se almacenan en el activo y no se pueden cambiar en el futuro; este proceso se denomina "horneado". Si necesita cambiar dinámicamente los obstáculos, puede usar el componente NavMeshObstacle, pero esto no es necesario para nuestro juego.

Un punto importante: para que Unity sepa qué objetos deben incluirse en el cálculo, en el Inspector para cada objeto (puede seleccionar todo a la vez en la ventana Jerarquía), haga clic en la flecha hacia abajo junto a la opción "Estática" y marque "Estática de navegación":



En general, los puntos restantes también son útiles y ayudan a Unity a optimizar la representación de la escena. No nos detendremos en ellos hoy, pero cuando termine de aprender los conceptos básicos del motor, le recomiendo encarecidamente que también se ocupe de otros parámetros. A veces, una sola marca de verificación puede aumentar significativamente la velocidad de fotogramas.

Ahora usaremos el elemento de menú Ventana⇨AI⇨Navegación y en la ventana que se abre, seleccione la pestaña “Hornear”. Aquí, Unity nos ofrecerá establecer parámetros tales como la altura y el radio del personaje, el ángulo máximo de inclinación de la tierra sobre el que aún puede caminar, la altura máxima de los escalones, etc. Todavía no cambiaremos nada y simplemente presione el botón "Hornear".



Unity hará los cálculos necesarios y nos mostrará el resultado:



Aquí, el azul indica el área donde puedes caminar. Como puede ver, Unity dejó un pequeño lado alrededor de los obstáculos: el ancho de este lado depende del radio del personaje. Por lo tanto, si el centro del personaje está en la zona azul, entonces no "caerá" los obstáculos.

Con una cuadrícula de navegación calculada, podemos usar el componente NavMeshAgent para buscar la ruta de movimiento y controlar el movimiento de los objetos del juego a nuestro nivel.

Creemos un objeto de juego "Zombie", agreguemos un modelo 3D de zombies a partir de activos, y también el componente NavMeshAgent:



Si comienzas el juego ahora, no pasará nada. Necesitamos decirle al componente NavMeshAgent a dónde ir. Para hacer esto, crearemos nuestro primer componente en C #.

En la ventana del proyecto, seleccione el directorio raíz (se llama "Activos") y en la lista de archivos, haga clic con el botón derecho para crear el directorio "Scripts". Almacenaremos todos nuestros scripts para que el proyecto tenga orden. Ahora, dentro de los "Scripts", creemos un script "Zombie" y añádalo al objeto del juego zombie:



Al hacer doble clic en el script, se abrirá en el editor. Veamos qué ha creado Unity para nosotros.

using System.Collections; using System.Collections.Generic; using UnityEngine; public class Zombie : MonoBehaviour { // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { } } 

Este es un componente estándar en blanco. Como podemos ver, Unity conectó las bibliotecas System.Collections y System.Collections.Generic (ahora no son necesarias, pero a menudo son necesarias en el código de juegos de Unity, por lo que están incluidas en la plantilla estándar), así como la biblioteca UnityEngine, que contiene todos API del motor central.

Además, Unity creó la clase Zombie para nosotros (el nombre coincide con el nombre del archivo; esto es importante: si no coinciden, Unity no podrá hacer coincidir el script con el componente en la escena). La clase se hereda de MonoBehaviour: esta es la clase base para los componentes creados por el usuario.

Dentro de la clase, Unity creó dos métodos para nosotros: Inicio y Actualización. El motor llamará a estos métodos: Iniciar - inmediatamente después de que se haya cargado la escena, y Actualizar - cada cuadro. De hecho, hay muchas de estas funciones llamadas por el motor, pero la mayoría de ellas no las necesitaremos hoy. La lista completa, así como la secuencia de su llamada, siempre se pueden encontrar en la documentación: https://docs.unity3d.com/Manual/ExecutionOrder.html

¡Hagamos que los zombies se muevan en el mapa!

Primero, necesitamos conectar la biblioteca UnityEngine.AI. Contiene la clase NavMeshAgent y otras clases relacionadas con la cuadrícula de navegación. Para hacer esto, agregue la directiva using UnityEngine.AI al comienzo del archivo.

Luego, necesitamos acceder al componente NavMeshAgent. Para hacer esto, podemos usar el método GetComponent estándar. Le permite obtener un enlace a cualquier componente en el mismo objeto del juego en el que se encuentra el componente desde el que llamamos este método (en nuestro caso, es el objeto del juego "Zombie"). Crearemos el campo NavMeshAgent navMeshAgent en la clase, en el método Start obtendremos un enlace a NavMeshAgent y le pediremos que se mueva al punto (0, 0, 0). Deberíamos obtener este script:

 using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class Zombie : MonoBehaviour { NavMeshAgent navMeshAgent; // Start is called before the first frame update void Start() { navMeshAgent = GetComponent<NavMeshAgent>(); navMeshAgent.SetDestination(Vector3.zero); } // Update is called once per frame void Update() { } } 

Al comenzar el juego, veremos cómo el zombi se mueve al centro del mapa:



Zombis persiguiendo a una víctima


Genial Pero nuestros zombis están aburridos y solitarios, agreguemos la víctima de un jugador al juego para él.

Por analogía con zombies, crearemos un objeto de juego "Player" (esta vez seleccionaremos un modelo 3D de un oficial de policía), también agregaremos el componente NavMeshAgent y el script Player recién creado. Todavía no tocaremos el contenido del script de Player, pero necesitaremos hacer cambios en el script de Zombie. Además, recomiendo establecer el valor de propiedad Prioridad del jugador en 10 en el componente NavMeshAgent (o cualquier otro valor menor que el 50 estándar, es decir, darle al jugador una prioridad más alta). En este caso, si el jugador y los zombis se encuentran en el mapa, los zombis no podrán mover al jugador, mientras que el jugador podrá expulsar a los zombis.

Para perseguir a un jugador, un zombie necesita saber su posición. Y para esto necesitamos obtener un enlace en nuestra clase Zombie usando el método estándar FindObjectOfType. Recordando el enlace, podemos recurrir al componente de transformación del jugador y pedirle el valor de la posición. Y para que el zombie persiga al jugador siempre, y no solo al comienzo del juego, estableceremos un objetivo para NavMeshAgent en el método de actualización. Obtiene el siguiente script:

 using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class Zombie : MonoBehaviour { NavMeshAgent navMeshAgent; Player player; // Start is called before the first frame update void Start() { navMeshAgent = GetComponent<NavMeshAgent>(); player = FindObjectOfType<Player>(); } // Update is called once per frame void Update() { navMeshAgent.SetDestination(player.transform.position); } } 

Ejecuta el juego y asegúrate de que el zombi haya encontrado a su víctima:



Escape Escape


Nuestro jugador está parado como un ídolo. Claramente, esto no lo ayudará a sobrevivir en un mundo tan agresivo, por lo que debes enseñarle a moverse por el mapa.

Para hacer esto, necesitamos obtener información sobre las teclas presionadas desde Unity. ¡El método GetKey de la clase de entrada estándar solo proporciona dicha información!

Nota: en general, esta forma de obtener información no es totalmente canónica. Es mejor usar Input.GetAxis y enlazar a través de Project Settings⇨Input Manager. Mejor aún, nuevo sistema de entrada . Pero este artículo resultó ser demasiado largo y, por lo tanto, hagámoslo de la manera más simple.

Abra el script del reproductor y cámbielo de la siguiente manera:

 using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class Player : MonoBehaviour { NavMeshAgent navMeshAgent; public float moveSpeed; // Start is called before the first frame update void Start() { navMeshAgent = GetComponent<NavMeshAgent>(); } // Update is called once per frame void Update() { Vector3 dir = Vector3.zero; if (Input.GetKey(KeyCode.LeftArrow)) dir.z = -1.0f; if (Input.GetKey(KeyCode.RightArrow)) dir.z = 1.0f; if (Input.GetKey(KeyCode.UpArrow)) dir.x = -1.0f; if (Input.GetKey(KeyCode.DownArrow)) dir.x = 1.0f; navMeshAgent.velocity = dir.normalized * moveSpeed; } } 

Como en el caso de los zombis, en el método de Inicio obtenemos un enlace al componente NavMeshAgent del jugador y lo almacenamos en el campo de clase. Pero ahora también agregamos el campo moveSpeed.
¡Debido a que este campo es público, su valor se puede editar directamente en el Inspector en Unity! Si tienes un diseñador de juegos en tu equipo, estará muy contento de no necesitar ingresar el código para editar los parámetros del jugador.

Establecer 10 como velocidad:



En el método de actualización usaremos Input.GetKey para verificar si alguna de las flechas del teclado está presionada y formar un vector de dirección para el jugador. Tenga en cuenta que usamos las coordenadas X y Z. Esto se debe al hecho de que en Unity el eje Y mira hacia el cielo y la tierra se encuentra en el plano XZ.

Después de haber formado un vector de dirección para el directorio de dirección de movimiento, lo normalizamos (de lo contrario, si el jugador quiere moverse en diagonal, el vector será un poco más largo que uno solo y este movimiento será más rápido que moverse directamente) y se multiplicará por la velocidad de movimiento dada. El resultado se pasa a navMeshAgent.velocity y el agente hará el resto.

Al iniciar el juego, finalmente podemos intentar escapar de los zombies a un lugar seguro:



Para hacer que la cámara se mueva con el jugador, escriba otro guión simple. Llamémoslo "PlayerCamera":

 using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerCamera : MonoBehaviour { Player player; Vector3 offset; // Start is called before the first frame update void Start() { player = FindObjectOfType<Player>(); offset = transform.position - player.transform.position; } // Update is called once per frame void LateUpdate() { transform.position = player.transform.position + offset; } } 

El significado de este script debe entenderse en gran medida. De las características: aquí, en lugar de Actualizar, usamos LateUpdate. Este método es similar a Update, pero siempre se llama estrictamente después de que Update se haya completado para todos los scripts en la escena. En este caso, usamos LateUpdate, porque es importante para nosotros que NavMeshAgent calcule la nueva posición del reproductor antes de mover la cámara. De lo contrario, puede producirse un desagradable efecto de "sacudidas".

Si ahora adjuntas este componente al objeto del juego "Cámara principal" y comienzas el juego, ¡el personaje del jugador siempre estará en el centro de atención!

Momento de animación


Por un momento nos apartamos de los problemas de supervivencia en las condiciones de un apocalipsis zombie y pensamos en lo eterno, en el arte. Nuestros personajes ahora parecen estatuas animadas, puestas en movimiento por una fuerza desconocida (posiblemente imanes debajo del asfalto). Y me gustaría que parecieran personas reales, vivas (y no muy): movieron sus brazos y piernas. El componente Animator y una herramienta llamada Animator Controller nos ayudarán con esto.

Animator Controller es una máquina de estados finitos (máquina de estados), donde establecemos ciertos estados (el personaje está de pie, el personaje está encendido, el personaje está muriendo, etc.), les adjuntamos animaciones y establecemos las reglas para la transición de un estado a otro. Unity cambiará automáticamente de una animación a otra tan pronto como funcione la regla correspondiente.

Creemos un controlador Animator para zombies. Para hacer esto, cree el directorio de Animaciones en el proyecto (recuerde el orden en el proyecto), y en él, con el botón derecho, Animator Controller. Y llamémosle "Zombi". Haga doble clic y el editor aparecerá ante nosotros:



No hay estados aquí hasta ahora, pero hay dos puntos de entrada ("Entrada" y "Cualquier estado") y un punto de salida ("Salida"). Arrastre un par de animaciones desde los activos:



Como puede ver, tan pronto como arrastramos la primera animación, Unity la vincula automáticamente al punto de entrada de entrada. Esta es la llamada animación predeterminada. Se jugará inmediatamente después del inicio del nivel.

Para cambiar a un estado diferente (y reproducir otra animación), necesitamos crear reglas de transición. Y para esto, antes que nada, necesitaremos agregar un parámetro que estableceremos desde el código para administrar las animaciones.

Hay dos botones en la esquina superior izquierda de la ventana del editor: "Capas" y "Parámetros". Por defecto, la pestaña "Capas" está seleccionada, pero necesitamos cambiar a "Parámetros". Ahora podemos agregar un nuevo parámetro de tipo float usando el botón "+". Llamémoslo "velocidad":



Ahora tenemos que decirle a Unity que la animación "Z_run" debe reproducirse cuando la velocidad es mayor que 0 y "Z_idle_A" cuando la velocidad es cero. Para hacer esto, debemos crear dos transiciones: una de “Z_idle_A” a “Z_run”, y la otra en la dirección opuesta.

Comencemos con la transición de inactivo a ejecutar. Haga clic derecho en el rectángulo "Z_idle_A" y seleccione "Realizar transición". Aparecerá una flecha, haciendo clic en la cual puede configurar sus parámetros. Primero, debe desmarcar "Tiene tiempo de salida". Si esto no se hace, la animación cambiará no según nuestra condición, sino cuando la anterior termine de reproducirse. No necesitamos esto en absoluto, así que lo desmarcamos. En segundo lugar, en la parte inferior, en la lista de condiciones ("Condiciones") debe hacer clic en "+" y Unity nos agregará una condición. Los valores predeterminados en este caso son exactamente lo que necesitamos: el parámetro "velocidad" debe ser mayor que cero para cambiar de inactivo a ejecutar.



Por analogía, creamos una transición en la dirección opuesta, pero como condición ahora especificamos "velocidad" menor que 0.0001. No hay verificaciones de igualdad para los parámetros de tipo flotante, solo se pueden comparar por más / menos:



Ahora debes vincular el controlador al objeto del juego. Seleccionaremos el modelo 3D del zombie en la escena (este es un elemento secundario del objeto "Zombie") y arrastraremos el controlador con el mouse al campo correspondiente en el componente Animator:



¡Solo queda escribir un script que controle el parámetro de velocidad!

Cree el script de MotionAnimator con los siguientes contenidos:

 using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class MovementAnimator : MonoBehaviour { NavMeshAgent navMeshAgent; Animator animator; // Start is called before the first frame update void Start() { navMeshAgent = GetComponent<NavMeshAgent>(); animator = GetComponentInChildren<Animator>(); } // Update is called once per frame void Update() { animator.SetFloat("speed", navMeshAgent.velocity.magnitude); } } 

Aquí, como en otras secuencias de comandos, en el método de Inicio tenemos acceso a NavMeshAgent. También tenemos acceso al componente Animator, pero dado que adjuntaremos el componente "MovementAnimator" al objeto del juego "Zombie" y el Animator está en el objeto secundario, en lugar de GetComponent necesitamos usar el método GetComponentInChildren estándar.

En el método de actualización, le pedimos a NavMeshAgent su vector de velocidad, calcule su longitud y se lo pase al animador como parámetro de velocidad. ¡Sin magia, todo en ciencia!

Ahora agregue el componente MotionAnimator al objeto del juego Zombie y, si el juego comienza, vemos que los zombies ahora están animados:



Tenga en cuenta que, dado que hemos colocado el código de control del animador en un componente separado de MotionAnimation, se puede agregar fácilmente para el jugador. Ni siquiera necesitamos crear un controlador desde cero: puede copiar un controlador zombie (esto se puede hacer seleccionando el archivo "Zombie" y presionando Ctrl + D) y reemplazar las animaciones en los rectángulos de estado con "m_idle_" y "m_run". Todo lo demás es como un zombie. Lo dejaré para usted como ejercicio (bueno, o descargue el código al final del artículo).

Una pequeña adición que es útil hacer es agregar las siguientes líneas a la clase Zombie:

En el método de Inicio:

 navMeshAgent.updateRotation = false; 

En el método de actualización:

 transform.rotation = Quaternion.LookRotation(navMeshAgent.velocity.normalized); 

La primera línea le dice a NavMeshAgent que no debe controlar la rotación del personaje, lo haremos nosotros mismos. La segunda línea establece el giro del personaje en la misma dirección donde se dirige su movimiento. NavMeshAgent por defecto interpola el ángulo de rotación del personaje y esto no se ve muy bien (el zombi gira más lentamente que cambia la dirección del movimiento). Agregar estas líneas elimina este efecto.

NB Usamos el cuaternión para especificar la rotación. En los gráficos tridimensionales, las principales formas de especificar la rotación de un objeto son los ángulos de Euler, las matrices de rotación y los cuaterniones. Los dos primeros no siempre son convenientes de usar, y también están sujetos a un efecto tan desagradable como "Gimbal Lock". Los cuaterniones se ven privados de este inconveniente y ahora se usan casi universalmente. Unity proporciona herramientas convenientes para trabajar con cuaterniones (así como también con matrices y ángulos de Euler), lo que le permite no entrar en detalles del dispositivo de este aparato matemático.

Veo el gol


Genial, ahora podemos escapar de los zombies. Pero esto no es suficiente, tarde o temprano aparecerá un segundo zombie, luego un tercero, quinto, décimo ... pero no puedes huir de la multitud. Para sobrevivir, tienes que matar. Además, el jugador ya tiene una pistola en la mano.

Para que el jugador pueda disparar, debes darle la oportunidad de elegir un objetivo. Para hacer esto, coloque el cursor controlado por el mouse en el suelo.

En la pantalla, el cursor del mouse se mueve en un espacio bidimensional: la superficie del monitor. Al mismo tiempo, nuestra escena del juego es tridimensional. El observador ve la escena a través de su ojo, donde todos los rayos de luz convergen en un punto. Combinando todos estos rayos, obtenemos una pirámide de visibilidad:



El ojo del observador solo ve lo que cae dentro de esta pirámide. Además, el motor trunca específicamente esta pirámide desde dos lados: en primer lugar, desde el lado del observador hay una pantalla de monitor, el llamado "plano cercano" (en la figura está pintado en amarillo). El monitor no puede mostrar físicamente objetos más cerca que la pantalla, por lo que el motor los interrumpe. En segundo lugar, dado que la computadora tiene una cantidad finita de recursos, el motor no puede extender los rayos hasta el infinito (por ejemplo, se debe establecer un cierto rango de valores posibles para el búfer de profundidad; además, cuanto más ancho es, menor es la precisión), por lo que la pirámide se corta detrás de la llamada "Plano lejano".

Dado que el cursor del mouse se mueve a lo largo del plano cercano, podemos liberar el rayo desde el punto donde se encuentra profundamente en la escena. El primer objeto con el que se cruza será el objeto al que apunta el cursor del mouse desde el punto de vista del observador.



Para construir dicho rayo y encontrar su intersección con los objetos en la escena, puede usar el método estándar Raycast de la clase Física. Pero si usamos este método, encontrará la intersección con todos los objetos en la escena: tierra, paredes, zombis ... Pero queremos que el cursor se mueva solo en el suelo, por lo que debemos explicarle a Unity de alguna manera que la búsqueda de intersección debe ser limitada solamente un conjunto dado de objetos (en nuestro caso, solo los planos de la tierra).

Si selecciona cualquier objeto del juego en la escena, en la parte superior del inspector puede ver la lista desplegable "Capa". Por defecto habrá un valor de "Predeterminado". Al abrir la lista desplegable, puede encontrar el elemento "Agregar capa ...", que abrirá la ventana del editor de capas. En el editor necesita agregar una nueva capa (llamémosla "Ground"):



Ahora puede seleccionar todos los planos de tierra en la escena y usar esta lista desplegable para asignarles la capa de tierra. Esto nos permitirá indicar en el script al método Physics.Raycast que es necesario verificar la intersección del haz solo con estos objetos.

Ahora arrastremos el cursor del cursor desde los activos a la escena (uso Spags Assets⇨Textures⇨Demo⇨white_hip⇨white_hip_14):



Agregué una rotación de 90 grados alrededor del eje X al cursor para que quede horizontalmente en el suelo, establezca la escala en 0.25 para que no sea tan grande y establezca la coordenada Y en 0.01. Esto último es importante para que no haya un efecto llamado "Z-fighting". La tarjeta de video utiliza cálculos de coma flotante para determinar qué objetos están más cerca de la cámara. Si establece el cursor en 0 (es decir, el mismo que el del plano de tierra), entonces, debido a errores en estos cálculos, para algunos píxeles, la tarjeta de video decidirá que el cursor está más cerca, y para otros, que la tierra. Además, en diferentes cuadros, los conjuntos de píxeles serán diferentes, lo que creará un efecto desagradable de brillar las piezas del cursor a través del suelo y "parpadear" cuando se mueve. El valor de 0.01 es lo suficientemente grande como para compensar los errores en el cálculo de la tarjeta de video, pero no tan grande como para que el ojo note que el cursor está suspendido en el aire.

Ahora cambie el nombre del objeto del juego a Cursor y cree un script con el mismo nombre y el siguiente contenido:

 using System.Collections; using System.Collections.Generic; using UnityEngine; public class Cursor : MonoBehaviour { SpriteRenderer spriteRenderer; int layerMask; // Start is called before the first frame update void Start() { spriteRenderer = GetComponent<SpriteRenderer>(); layerMask = LayerMask.GetMask("Ground"); } // Update is called once per frame void Update() { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (!Physics.Raycast(ray, out hit, 1000, layerMask)) spriteRenderer.enabled = false; else { transform.position = new Vector3(hit.point.x, transform.position.y, hit.point.z); spriteRenderer.enabled = true; } } } 

Como el cursor es un sprite (dibujo bidimensional), Unity usa el componente SpriteRenderer para representarlo. Obtenemos un enlace a este componente en el método de Inicio para poder activarlo / desactivarlo según sea necesario.

También en el método de Inicio, convertimos el nombre de la capa "Ground" que creamos anteriormente en una máscara de bits. Unity utiliza operaciones bit a bit para filtrar objetos cuando busca intersecciones, y el método LayerMask.GetMask devuelve la máscara de bits correspondiente a la capa especificada.

En el método Actualizar, accedemos a la cámara principal de la escena usando Camera.main y le pedimos que recalcule las coordenadas bidimensionales del mouse (obtenidas usando Input.mousePosition) en un rayo tridimensional. Luego, pasamos este rayo al método Physics.Raycast y verificamos si se cruza con algún objeto en la escena. Un valor de 1000 es la distancia máxima. En matemáticas, los rayos son infinitos, pero los recursos informáticos y la memoria de una computadora no lo son. Por lo tanto, Unity nos pide que determinemos una distancia máxima razonable.

Si no hubo intersección, apagamos el SpriteRenderer y la imagen del cursor desaparece de la pantalla. Si se encontró la intersección, entonces movemos el cursor al punto de intersección.Tenga en cuenta que no cambiamos la coordenada Y, ya que el punto de intersección del rayo con el suelo tendrá Y igual a cero y al asignarlo a nuestro cursor nuevamente obtendremos el efecto de lucha Z, del cual tratamos de deshacernos arriba. Por lo tanto, tomamos solo las coordenadas X y Z desde el punto de intersección, e Y permanece igual.

Agregue el componente Cursor al objeto del juego Cursor.

Ahora, finalicemos la secuencia de comandos del reproductor: primero, agregue el campo del cursor del cursor. Luego, en el método de Inicio, agregue las siguientes líneas:

 cursor = FindObjectOfType<Cursor>(); navMeshAgent.updateRotation = false; 

Y finalmente, para que el jugador siempre gire hacia el cursor, en el método Actualizar, agregue:

 Vector3 forward = cursor.transform.position - transform.position; transform.rotation = Quaternion.LookRotation(new Vector3(forward.x, 0, forward.z)); 

Aquí tampoco tenemos en cuenta la coordenada Y.

Dispara para sobrevivir


El mero hecho de girar hacia el cursor no nos protegerá de los zombis, sino que solo aliviará al personaje del jugador del efecto de la sorpresa: ahora no puede acercarse sigilosamente detrás de él. Para que realmente pueda sobrevivir en las duras realidades de nuestro juego, debes enseñarle a disparar. ¿Y qué tipo de disparo es si no es visible? Todo el mundo sabe que cualquier tirador respetable siempre dispara balas trazadoras.

Cree un objeto de juego Shot y agregue el componente LineRenderer estándar. Usando el campo "Ancho" en el editor, dele un ancho pequeño, por ejemplo, 0.04. Como podemos ver, Unity lo pinta con un color púrpura brillante, de esta manera se resaltan los objetos sin material.

Los materiales son un elemento importante de cualquier motor tridimensional. El uso de materiales describe la apariencia del objeto. Todos los parámetros de iluminación, texturas, sombreadores: todo esto lo describe el material.

Creemos el directorio de Materiales en el proyecto y dentro de él el material, llamémoslo Amarillo. Como sombreador, seleccione Apagado / Color. Este sombreador estándar no incluye iluminación, por lo que nuestra bala será visible incluso en la oscuridad. Seleccione el color amarillo:



ahora que el material está creado, puede asignarlo a LineRenderer:



Crear una secuencia de comandos Shot:

 using System.Collections; using System.Collections.Generic; using UnityEngine; public class Shot : MonoBehaviour { LineRenderer lineRenderer; bool visible; // Start is called before the first frame update void Start() { lineRenderer = GetComponent<LineRenderer>(); } // Update is called once per frame void FixedUpdate() { if (visible) visible = false; else gameObject.SetActive(false); } public void Show(Vector3 from, Vector3 to) { lineRenderer.SetPositions(new Vector3[]{ from, to }); visible = true; gameObject.SetActive(true); } } 

Este script, como probablemente ya has adivinado, debe agregarse al objeto del juego Shot.

Aquí usé un pequeño truco para mostrar una toma en la pantalla para exactamente un cuadro con un mínimo de código. Primero, uso FixedUpdate en lugar de Update. El método FixedUpdate se llama a la frecuencia especificada (por defecto, 60 cuadros por segundo), incluso si la velocidad de cuadros real es inestable. En segundo lugar, configuro la variable visible, que configuro en verdadero cuando visualizo la toma en la pantalla. En el siguiente FixedUpdate, lo restablezco a falso, y solo en el siguiente cuadro apago el objeto del juego. Esencialmente, uso una variable booleana como contador de 1 a 0.

El método gameObject.SetActive activa o desactiva todo el objeto del juego en el que se encuentra nuestro componente. Los objetos apagados del juego no se dibujan en la pantalla y sus componentes no llaman a los métodos Actualizar, FixedUpdate, etc. El uso de este método le permite hacer que el disparo sea invisible cuando el jugador no está disparando.

También hay un método Show público en el script, que usaremos en el script Player para mostrar realmente la viñeta cuando se dispara.

Pero primero debe poder obtener las coordenadas del cañón de la pistola para que el disparo provenga del orificio correcto. Para hacer esto, encuentre el objeto Bip001⇨Bip001 Pelvis⇨Bip001 Spine⇨Bip001 R Clavícula⇨Bip001 R UpperArm⇨Bip001 R Forearm⇨Bip001 R Hand⇨R_hand_container⇨w_handgun en el modelo 3D del jugador y agregue el objeto secundario GunBarrel. Colóquelo de manera que esté justo al lado del cañón de la pistola:



ahora en el script del jugador, agregue los campos:

 Shot shot; public Transform gunBarrel; 


Agregue al método de inicio del script del reproductor:

 shot = FindObjectOfType<Shot>(); 

Y en el método de actualización:

 if (Input.GetMouseButtonDown(0)) { var from = gunBarrel.position; var target = cursor.transform.position; var to = new Vector3(target.x, from.y, target.z); shot.Show(from, to); } 

Como puede adivinar, el campo público gunBarrel agregado, como moveSpeed ​​anteriormente, estará disponible en el Inspector. Vamos a asignarle el objeto real del juego que creamos:



si ahora comenzamos el juego, ¡finalmente podremos disparar a los zombis!



Algo está mal aquí! Parece que los disparos no matan zombies, ¡sino que simplemente vuelan a través de ellos!

Bueno, por supuesto, si miras nuestro código de disparo, no rastreamos de ninguna manera si nuestro disparo golpeó al enemigo o no. Simplemente dibuja una línea en el cursor.

Esto es bastante fácil de arreglar. En el código para procesar clics del mouse en la clase Player, después de la línea var to = ... y antes de la línea shot.Show (...), agregue las siguientes líneas:

 var direction = (to - from).normalized; RaycastHit hit; if (Physics.Raycast(from, to - from, out hit, 100)) to = new Vector3(hit.point.x, from.y, hit.point.z); else to = from + direction * 100; 

Aquí usamos el conocido Physics.Raycast para dejar salir el rayo del cañón de una pistola y determinar si se cruza con algún objeto del juego.

Aquí, sin embargo, hay una advertencia: la bala todavía volará a través de los zombies. El hecho es que el autor del activo agregó un colisionador a los objetos del nivel (edificios, cajas, etc.). Y el autor del activo con los personajes no lo hizo. Arreglemos este molesto malentendido.

Un colisionador es un componente con el cual el motor de física determina las colisiones entre objetos. Por lo general, se usan formas geométricas simples como colisionadores: cubos, esferas, etc. Aunque este enfoque proporciona colisiones menos precisas, las fórmulas de intersección entre tales objetos son bastante simples y no requieren grandes recursos computacionales. Por supuesto, si necesita la máxima precisión, siempre puede sacrificar el rendimiento y usar MeshCollider. Pero no necesitamos una alta precisión, por lo que utilizaremos el componente CapsuleCollider:



ahora la bala no volará a través de los zombies. Sin embargo, los zombis siguen siendo inmortales.

Zombies - Zombie Death!


Primero agreguemos una animación de muerte al controlador de animación zombie. Para hacer esto, arrastre la animación AssetPacks⇨ToonyTinyPeople⇨TT_demo⇨animation⇨zombie⇨Z_death_A. Para activarlo, cree un nuevo parámetro muerto con el tipo de disparador. A diferencia de otros parámetros (bool, float, etc.), los disparadores no recuerdan su estado y se parecen más a una llamada de función: activaron un disparador: la transición funcionó y el disparador se restableció. Y dado que un zombie puede morir en cualquier estado, y si se detiene, y si se está ejecutando, agregaremos la transición desde el estado Cualquier estado:



agregue los siguientes campos al script Zombie:

 CapsuleCollider capsuleCollider; Animator animator; MovementAnimator movementAnimator; bool dead; 

En el método de Inicio de la clase Zombie, inserte:

 capsuleCollider = GetComponent<CapsuleCollider>(); animator = GetComponentInChildren<Animator>(); movementAnimator = GetComponent<MovementAnimator>(); 

Al comienzo del método de actualización, debe agregar una verificación:

 if (dead) return; 

Y finalmente, agregue el método público Kill a la clase Zombie:

 public void Kill() { if (!dead) { dead = true; Destroy(capsuleCollider); Destroy(movementAnimator); Destroy(navMeshAgent); animator.SetTrigger("died"); } } 

La asignación de nuevos campos, creo, es bastante obvia. En cuanto al método Kill, en él (si no estamos muertos) establecemos la bandera de muerte zombie y eliminamos los componentes CapsuleCollider, MovementAnimator y NavMeshAgent de nuestro objeto de juego, después de lo cual activamos la reproducción de la animación de muerte desde el controlador de animación.

¿Por qué eliminar componentes? De modo que tan pronto como muere un zombi, deja de moverse por el mapa y ya no es un obstáculo para las balas. Para bien, aún necesitas deshacerte del cuerpo de alguna manera hermosa después de que se haya reproducido la animación de la muerte. De lo contrario, los zombis muertos seguirán consumiendo recursos y, cuando haya demasiados cadáveres, el juego se ralentizará notablemente. La forma más fácil es agregar la llamada Destroy (gameObject, 3) aquí. Esto hará que Unity elimine este objeto del juego 3 segundos después de esta llamada.

Para que todo esto finalmente funcionara, el último toque permaneció. En la clase Player, en el método Update, donde llamamos Physics.Raycast, en la rama para el caso en que se encontró una intersección, agregamos un cheque:

 if (hit.transform != null) { var zombie = hit.transform.GetComponent<Zombie>(); if (zombie != null) zombie.Kill(); } 

Physics.Raycast llama a la información de intersección en la variable de hit. En particular, en el campo de transformación habrá un enlace al componente Transformar del objeto del juego con el que se cruzó el rayo. Si este objeto del juego tiene un componente Zombie, entonces es un zombie y lo matamos. Elemental!

Bueno, para que la muerte del enemigo se vea espectacular, agregamos un sistema de partículas simple a los zombis.

Los sistemas de partículas le permiten controlar una gran cantidad de objetos pequeños (generalmente sprites) de acuerdo con algún tipo de ley física o fórmula matemática. Por ejemplo, puede hacer que se separen o que vuelen hacia abajo a cierta velocidad. Con la ayuda de los sistemas de partículas en los juegos, se realizan todo tipo de efectos: fuego, humo, chispas, lluvia, nieve, suciedad debajo de las ruedas, etc. Utilizaremos un sistema de partículas para que, en el momento de la muerte, la sangre salpique de un zombi.

Agregue un sistema de partículas al objeto del juego Zombie (haga clic derecho sobre él y seleccione Efectos⇨Sistema de partículas):

sugiero las siguientes opciones:
Transformar:

  • Posición: Y 0.5
  • Rotación: X -90

Sistema de partículas
  • Duración: 0.2
  • Bucle: falso
  • Inicio de la vida: 0.8
  • Tamaño de inicio: 0.5
  • Color de inicio: verde
  • Modificador de gravedad: 1
  • Jugar en despierto: falso
  • Emisión:
  • Tasa a lo largo del tiempo: 100
  • Forma:
  • Radio: 0.25

Debería verse así:



queda por activarlo en el método Kill de la clase Zombie:

 GetComponentInChildren<ParticleSystem>().Play(); 

¡Y ahora un asunto completamente diferente!



Ataque de zombis en rebaño


De hecho, luchar contra un solo zombie es aburrido. Lo mataste y eso es todo. ¿Dónde está el drama? ¿Dónde está el miedo a morir joven? Para crear una verdadera atmósfera de apocalipsis y desesperanza, debe haber muchos zombis.

Afortunadamente, esto es bastante simple. Como habrás adivinado, necesitamos otro script. Llámalo EnemySpawner y llénalo con los siguientes contenidos:

 using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemySpawner : MonoBehaviour { public float Period; public GameObject Enemy; float TimeUntilNextSpawn; // Start is called before the first frame update void Start() { TimeUntilNextSpawn = Random.Range(0, Period); } // Update is called once per frame void Update() { TimeUntilNextSpawn -= Time.deltaTime; if (TimeUntilNextSpawn <= 0.0f) { TimeUntilNextSpawn = Period; Instantiate(Enemy, transform.position, transform.rotation); } } } 

Usando el campo público Periodo, el diseñador del juego puede establecer en el Inspector con qué frecuencia se debe crear un nuevo enemigo. En el campo Enemigo, indicamos qué enemigo crear (hasta ahora solo tenemos un enemigo, pero en el futuro podemos agregar más). Bueno, entonces todo es simple: usando TimeUntilNextSpawn contamos cuánto tiempo queda hasta la próxima aparición del enemigo y, tan pronto como llegue el momento, agregamos un nuevo zombie a la escena usando el método estándar de instanciación. Ah, sí, en el método de Inicio, asignamos un valor aleatorio al campo TimeUntilNextSpawn, de modo que si tenemos varios reproductores con el mismo retraso en el nivel, no agregarán zombis al mismo tiempo.

Queda una pregunta: ¿cómo preguntarle al enemigo en el campo Enemigo? Para hacer esto, utilizaremos una herramienta de Unity como "Prefabs". De hecho, un prefabricado es una parte de la escena guardada en un archivo separado. Luego, podemos insertar este archivo en otras escenas (o en la misma) y no es necesario volver a recopilarlo en partes cada vez. Por ejemplo, recolectamos, de los objetos de paredes, piso, techo, ventanas y puertas, una hermosa casa y la guardamos como una casa prefabricada. Ahora puede insertar esta casa en otras tarjetas con un simple movimiento de muñeca. Al mismo tiempo, si edita el archivo prefabricado (por ejemplo, agrega una puerta trasera a la casa), el objeto cambiará en todas las escenas. A veces es muy conveniente. También podemos usar prefabricados como plantillas para Instantiate, y aprovecharemos esta oportunidad ahora mismo.

Para crear un prefabricado, simplemente arrastre el objeto del juego desde la ventana de jerarquía a la ventana del proyecto, Unity hará el resto. Creemos una casa prefabricada de zombies y luego agreguemos un generador de enemigos a la escena:



agregué tres reproductores más en el proyecto para un cambio (así que, al final, tengo 4 de ellos). Y entonces, qué pasó:



¡Aquí! ¡Ya parece un apocalipsis zombie!

Conclusión


Por supuesto, esto está lejos de ser un juego completo. No consideramos muchos problemas, como la creación de una interfaz de usuario, sonidos, vidas y muerte de un jugador; todo esto queda fuera del alcance de este artículo. Pero me parece que este artículo será una introducción digna a Unity para aquellos que no están familiarizados con esta herramienta. ¿O tal vez alguien experimentado podrá sacar algún truco de eso?

En general, amigos, espero que hayan disfrutado mi artículo. Escribe tus preguntas en los comentarios, intentaré responderte. El código fuente del proyecto se puede descargar en el github: https://github.com/zapolnov/otus_zombies . Necesitará Unity 2019.3.0f3 o superior, se puede descargar de forma totalmente gratuita y sin SMS desde el sitio web oficial: https://store.unity.com/download .

Enlaces a los activos utilizados en el artículo:

Source: https://habr.com/ru/post/485210/


All Articles