Engenharia Insper / Laboratório de Realidade Virtual e Jogos Digitais
Shmup parte 2
Os jogos de tiro eram muito populares no fim do século passado, e de certa forma continuam sendo, embora as possibilidades sejam muito maiores atualmente. Já, os jogos ditos "Bullet Hell" são um subgênero que apresenta um número extremamente exagerado de tiros para tudo que é lado, tanto do jogador, quanto dos inimigos.
Jogo de Tela rolante Shoot em’ Up de Naves, em que o jogador irá controlar uma nave contra diversos outros inimigos. A Nave do jogador começará simples e pode ganhar novos elementos com a progressão do jogo, assim como maiores desafios.
Sprite controlado pelo jogador, deve mover-se livremente pela tela, enquanto avança pelo jogo. Caso colida com inimigos ou outros objetos deverá explodir e retirar um ponto de vida do jogador.
Controles:
Inimigos fixos apresentam a menor dificuldade para o jogador, ficando estáticos enquanto o jogo progride.
Inimigos volantes deve ter seu movimento, controlado por alguma função periódica ou composição destas. (https://en.wikipedia.org/wiki/List_of_periodic_functions)
Variante do inimigo fixo, mas deve conter um elemento que atire projéteis.
Variante do Inimigo volante, mas deve conter um elemento que atire projéteis.
Devem manter uma trajetória retilínea.
Projéteis Inimigos:
Danificam a nave do jogador.
Projéteis do player:
Danificam as naves inimigas.
Quando o jogador conseguir 10000 Pontos.
Quando os pontos de vida chegarem a 0.
Sprites adaptados de free pixel art enemy spaceship 2d sprites:
https://craftpix.net/freebies/free-pixel-art-enemy-spaceship-2d-sprites/
https://opengameart.org/content/a-layered-asteroid-rock
Telas para o usuário começar o jogo, ver placar e número de vidas, e depois reiniciar ao final de uma partida.
No tutorial anterior criamos alguns elementos básicos de um jogo de Shoot'em up, porém só com o que passamos no documento não é suficiente para criar um jogo completo. Para podermos evoluir os elementos da cena, e por consequente o jogo, precisamos trabalhar em alguns pontos, e vamos começar aqui com a câmera virtual do jogo.
A câmera de nosso jogo ainda está fixa, ou seja, ela não se altera durante o jogo, assim podemos pensar em algum truque para dar mais dinamismo ao jogo se fizermos algumas operações na câmera, como transladar ela durante o partida (por padrão as cenas do Unity são sempre criadas com uma câmera virtual).
Vamos trabalhar em uma forma de fazer com que a câmera siga a nave do jogador. Existem diversas formas de fazer isso, mas aqui utilizaremos Constrains (restrições) que é uma forma de resolver isso rapidamente. Localize a MainCamera na sua janela de hierarquia, e clique em Add Component pelo Inspector, e procure por "Constraint".
Existem diversos tipos de Constraints, neste momento utilizaremos a Parent Constraint, isso fará com que a câmera acompanhe o movimento do jogador como se estivesse presa na hierarquia. Adicione o elemento Parent Constraint.
Agora temos de dizer para esse Constraint o que e como desejamos que ele siga. No caso será a nave do jogador, assim para adicionar uma fonte de constraint (source) clique no botão + na parte de baixo do componente, e depois selecione o nosso GameObject do jogador, que é a nave, e arraste para esse campo. Para que o Constraint funcione, precisamos que ele esteja ativado, assim deixe marcado o campo Is Active.
Pronto, agora teste para ver se tudo está funcionando direitinho.
Bem, nesse momento a câmera está seguindo todas as rotações e translações do objeto nave, vamos parar de seguir as translações em Y e Z. Em Freeze Position queremos que a câmera siga apenas a posição X do personagem, assim deixamos apenas ela selecionada, se ativarmos os outros eixos esses também serão definidos pela posição do jogador.
A propriedade Weight define quão forte queremos que seja essa constraint. Pense que seria como se pegássemos o deslocamento e fizéssemos um desconto. Você pode deixar ele em um mesmo.
No final seu Parent Constraint deve ficar com a seguinte aparência:
O que é um Shoot ‘em up sem tiros? Nessa sessão vamos ver como faremos isso.
Para começar precisamos de uma forma de representar o tiro, e aqui novamente uma sprite pode ser uma boa solução. A forma mais simples de criar uma Sprite é criando ela pelo menu Assets.
Ao fazer isso seu Sprite está no projeto, mas não na cena, assim adicione ele na cena.
Assim como o Inimigo Volante do tutorial anterior, queremos que o componente que controle nosso tiro herde de SteerableBehaviour. Crie um novo script chamado ShotBehaviour e faça ele herdar de SteerableBehaviour.
ShotBehaviour.cs |
public class ShotBehaviour : SteerableBehaviour |
Associe esse script no objeto de Sprite Tiro que acabou de criar pelo Add Component, por exemplo.
Como sabemos que esse script precisa de um ScriptableObject, vamos criar um novo para definir as velocidades do tiro. Para isso vá em Assets > Create > ScriptableObjects > ThrustData.
Ajuste os valores que achar necessário (depois você pode ir melhor ajustando).
Atribuía no inspector para o componente criado.
No Rigidbody 2D vamos desligar os efeitos da gravidade para que nossa Sprite não fique caindo. Para isso coloque o Gravity Scale em 0 (zero).
Agora faremos o tiro andar pela tela, para isso, precisamos chamar o método Thrust pela rotina Update() do Script ShotBehaviour, faça então essa adição:
ShotBehaviour.cs |
private void Update() |
Conforme necessário ajuste a escala do seu tiro usando a ferramenta de escalonamento. Por final transforme esse tiro que acabamos de criar em um Prefab (basta arrastar para a janela de projeto).
Instanciar e destruir elementos é algo muito comum nos jogos. O método `instantiate` aparece de diversas formas:
https://docs.unity3d.com/ScriptReference/Object.Instantiate.html https://rvinsper.itch.io/demonstracao-instantiations |
Para instanciar algum elemento é necessário que ele seja de fácil acesso, uma das formas mais fáceis de se fazer isso é criar uma referência que você consiga atribuir no inspector, algo como:
public GameObject _original |
Existem diversas outras formas de conseguir esse objeto para fazer um clone dele, no fim do tutorial você pode encontrar como nas leituras complementares.
Vamos preparar os script para instanciar os tiros quando quisermos atirar, no script PlayerController vemos que ele implementa uma interface do tipo IShooter. Interfaces são boas para organizar nosso código e deixá-lo mais coeso.
Nesse caso, a IShooter deve ser implementada para todos que forem atirar em nosso jogo. Ela define apenas um método que é o método Shoot e precisamos implementá-lo. Se você ver no IShooter.cs entregue no pacote inicia verá:
IShooter.cs (já disponibilizado no pacote inicial) |
public interface IShooter |
Vamos então implementar o método Shoot() no PlayerController da nave. Ajuste o código para instanciar os objetos de tiro que acabamos de criar.
PlayerController.cs |
public class PlayerController : SteerableBehaviour, IShooter, IDamageable |
Agora temos de chamar essa rotina de alguma forma, assim vamos colocar na rotina de update() uma checagem de quando apertamos alguma tecla e conforme for chamarmos o método Shoot(). Vamos usar o GetAxixRaw com a opção "Fire1", que no teclado do seu computador deve ser a tecla Control (Ctrl).
PlayerController.cs |
private void FixedUpdate() ... ... |
Não esqueça de definir a referência para o objeto Tiro pelo inspector.
No código acima, colocamos o botão "Fire1" para disparar os tiros, por padrão isso representa o botão Ctrl (Control) do teclado do computador. Experimente rodar o jogo e pressionar a tecla Ctrl. Se preferir mudar para a barra de espaço, troque o "Fire1" para "Jump".
Se fizermos apenas isso, você pode perceber que há alguma coisa esquisita. Além de estarmos criando inúmeras instâncias do tiro de forma descontrolada, está aparecendo no lugar errado.
Podemos corrigir isso, vamos passar como coordenada a própria posição da nave, e vamos passar também uma rotação padrão (que é feita com o Quaternion.identity) com a seguinte modificação:
PlayerController.cs |
Instantiate(bullet, transform.position, Quaternion.identity); |
Isso fará com que os tiros sejam criados na posição do jogador. Caso esteja com problemas do tiro saindo de dentro da nave, você pode adicionar alguma distância no position. Por exemplo:
transform.position + new Vector3(1.0f, 0.0f, 0.0f) |
Outra solução, mais recomendada é usar um objeto para que o tiro saia exatamente de um local. Para isso crie um EmptyObject filho do Player, e o renomeie ele, por exemplo para Arma01.
Para ficar ainda mais fácil de ver onde está o objeto, visto que está vazio, clique no cubo que fica ao lado do nome do GameObject no inspector e selecione algum marcador.
Com o marcador fica mais fácil de ver onde exatamente está a posição da “Arma01”
Posicione onde achar melhor, e agora no jogador precisamos criar uma referência para essa arma.
PlayerController.cs |
public GameObject bullet; |
Faça a atribuição no inspector, e atualize o método Shoot().
PlayerController.cs |
Instantiate(bullet, arma01.position, Quaternion.identity); |
Resolvemos um dos problemas, a posição, agora vamos resolver o exagero de tiros que estamos instanciando. Para isso, a forma mais fácil é registrar quando que foi realizado o último tiro e então calcular se existe uma diferença de tempo suficiente para podermos atirar novamente.
PlayerController.cs |
private int _lifes; private float _lastShootTimestamp = 0.0f; |
Teste para ver como está ficando, dessa vez devemos ter um tiro controlado.
Durante o jogo muitos objetos poderão ser criados e o desempenho da sua aplicação comprometido, se desejar ver como monitorar a complexidade do seu projeto, vá para o seguinte documento: Otimizando: Overview do Profiler
Agora que temos o tiro criado e sendo instanciado, precisamos atribuir o dano do tiro nos elementos que ele tocar, para isso precisamos escrever um código para gerenciar isso. Veja o seguinte código para o ShotBehaviour:
ShotBehaviour.cs |
private void OnTriggerEnter2D(Collider2D collision) |
Este código é um pouco mais complicado que apenas um Destroy ( collision.gameObject ) , pois estamos fazendo o uso do conhecimento da arquitetura do projeto, e todo objeto que irá sofrer dano deve implementar a Interface IDamageable.
Com o código acima, aplicaremos o dano em qualquer classe que implemente a interface IDamageable.
Agora, garanta que os Colliders tanto do Player como dos inimigos sejam Trigger.
Vamos implementar o TakeDamage() nas classes específicas de Inimigo e do Player. Na classe EnemyController você só precisa terminar de implementar o método TakeDamage() como abaixo.
EnemyController.cs |
public void TakeDamage() |
Já no Player você pode aproveitar e controlar o número de vidas.
PlayerController.cs |
private int lifes; |
Tente executar seu jogo agora.
Provavelmente você encontrou um problema, o tiro está colidindo com nossa nave e a destruindo automaticamente, precisamos fazer um filtro. Podemos fazer com que os tiros da nave não destruam objetos do tipo Player, ou seja, a própria nave. Assim, garanta que a nave está com a Tag "Player".
E use o seguinte código:
ShotBehaviour.cs |
private void OnTriggerEnter2D(Collider2D collision) |
Faça agora a implementação do IDamageable na pedra e no inimigo animado, obviamente com o TakeDamage(). Veja se funciona bem.
A proposta apresentada é simples mas funciona, se você deseja ver uma proposta mais elaborada, que vai levar a um maior desafio, siga para o seguinte documento: Otimizando: Object Pooling
O jogo está tomando forma, mas ainda não existe nenhum som, o que deixa ele muito “crú”. Inserir sons em nosso jogo dá uma camada a mais de imersão para nosso jogador. Há quem diga que aúdio é metade da imersão de um jogo.
Em jogos podemos dividir o áudio em 2 grandes grupos: Efeitos Sonoros (SFX - Sound Effects) que costumam ter poucos segundos; e Música Ambiente, que pode ser um loop curto como nos jogos mais antigos, ou composições completas com diversos minutos.
Audio Listener: Se você selecionar a câmera que vem por padrão em sua cena você pode perceber que ela contém esse componente, ele funciona como um microfone para captar os sons que serão reproduzidos em nossa cena, e transmiti-los para o áudio do computador.
É bastante comum esse componente estar na câmera do jogo, e apenas um Audio Listener por cena é permitido.
Para reproduzir um som para ser capturado pelo Audio Listener precisamos definir um ou vários Audio Source. Para este projeto criaremos uma estrutura bastante simples, que irá gerenciar os dois tipos de áudio, uma para os efeitos sonoros (SFX) e outra para a música de fundo.
Crie a seguinte estrutura de EmptyObjects em sua hierarquia:
E em SFX e Ambience adicione o componente Audio Source. Para isso vá na janela de hierarquia, Add Component e então Audio Source.
Feito isso iremos criar o Script AudioManager e inseri-lo no GameObject de mesmo nome, nesse script será necessário referências para os AudioSource que criamos.
AudioManager.cs |
using UnityEngine; |
Precisaremos de AudioClips, que são os arquivos de áudio que importamos para a Unity, eles podem ser de diversos formatos, sendo os mais comuns, MP3, OGG e WAV. Importe dois arquivos para o seu jogo.
Nos seguintes sites é possível encontrar diversos efeitos sonoros e músicas. Lembre-se sempre de dar os créditos:
Para facilitar o uso teremos uma parte estática dessa classe e então precisamos definir uma referência para a instância que vamos utilizar.
Feito isso vamos implementar o método Awake, que será responsável por inicializar nosso gerenciador.
AudioManager.cs |
void Awake() |
E fazer a parte estática(static) da nossa classe, temos o Método PlaySFX para tocar um efeito sonoro qualquer, e a SetAmbience para mudarmos a música de fundo que está sendo tocada do momento.
AudioManager.cs |
public static void PlaySFX(AudioClip audioClip) |
E sempre que precisarmos tocar um efeito sonoro basta chamar
AudioManager.PlaySFX(MeuAudioClip); |
Podemos fazer uso disso para quando atiramos por exemplo, e então no script PlayerController criarmos uma referência para definir pelo Inspector qual som queremos tocar.
PlayerController.cs |
public AudioClip shootSFX; |
Fazemos uso do audio manager dessa forma, pois no caso de destruirmos um GameObject no jogo que tem um AudioSource esse também será destruído e podemos ficar com um áudio cortado no meio do nosso jogo. Dessa forma esse problema não acontece.
Verifique também se sua janela de Game não está em mudo. Se estiver, desligue para conseguir ouvir os sons.