Engenharia Insper / Laboratório de Realidade Virtual e Jogos Digitais

Shmup parte 1

Shumps ou Shoot ‘em Ups é um subgênero de jogos de tiro e ação. Conseguimos traçar sua história até o Spacewar!, um dos primeiros jogos de computador desenvolvido em 1962.  Costumam ser jogos com perspectiva de top (top-down) ou lateral (side-view), mas como não há um consenso de elementos que formam esse gênero, permite uma grande liberdade criativa, o que acabou criando diversos gêneros mais específicos como “Bullet Hells”, “Cute ‘em ups”, “Shoot The Core” e muitos outros.

 

By Kenneth Lu - Spacewar!, CC BY 2.0

By Video-game or computer emulator, Fair use

TV Tropes Bullet Hell

Proto Corgi on Steam

Objetivos de Aprendizagem


Requisitos (Documento de Requisitos)

Visão Geral

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.

Gameplay

Nave do Jogador

        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:

        Inimigo Fixo

        Inimigos fixos apresentam a menor dificuldade para o jogador, ficando estáticos enquanto o jogo progride.

        Inimigo Volante

        Inimigos volantes devem ter seu movimento controlado por alguma função periódica ou composição destas. (https://en.wikipedia.org/wiki/List_of_periodic_functions)

        Inimigo Fixo Atirador

Variante do inimigo fixo, mas deve conter um elemento que atire projéteis.

        Inimigo Volante Atirador

        Variante do Inimigo Volante, mas deve conter um elemento que atire projéteis.

        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.

Condição de Vitória

        Quando o jogador conseguir 10.000 Pontos.

Condição de Derrota

        Quando a quantidade de vidas chegar a 0.

Arte

        Neste exemplo as sprites foram adaptados de free pixel art enemy spaceship 2d sprites:

Interface do Usuário (UI - User Interface)

Telas para o usuário começar o jogo, ver placar e número de vidas, e depois reiniciar ao final de uma partida.

Fluxo de Telas

Texto alternativo gerado por máquina:
Fim de Jogo 
Jogo 
(Vitöria,'Derrota) 
Pause Menu


 Roteiro

Para este projeto vamos usar um conjunto de dados já prontos. Assim, primeiro crie um novo projeto 2D. Com o projeto aberto no editor do Unity vamos começar a trabalhar nele.

Nesse projeto vamos importar o pacote abaixo, assim faça o download dele:

https://drive.google.com/file/d/1Rl0mysiZ64trG2tFKbSRoAywYr9JN8cO/view?usp=sharing

Ao fazer o download você terá um arquivo chamado: shmupBase.unitypackage. Para importar esse novo pacote para o seu projeto, você pode clicar em: Assets > Import Package > Custom Package…  e selecionar o arquivo.

Quando o Unity perguntar o que deseja importar, você pode deixar da forma que está, com tudo selecionado e clicar em Import.

Se tudo correu bem você estará com o pacote já incorporado no seu projeto, assim, carregue a cena Scenes/SimpleScene.

Para ver como está a cena, clique no botão de Play na parte de cima do editor do Unity.

Temos, neste momento, uma cena extremamente simples, apenas com um inimigo em forma de hexágono, e um jogador em forma de triângulo controlado pelas teclas W A S D ou direcionais. Durante o documento veremos detalhes da implementação, e trabalharemos iterando em cima desta base.

Observação: Sempre é conveniente começar os projetos do zero para aprender de forma completa a desenvolver projetos, porém conseguir trabalhar com código de outros é outra habilidade importante, e aqui vamos começar com algo bem simples.


01 - Inimigo Fixo

Agora que já sabemos importar sprites vamos começar com o inimigo mais fácil de se fazer, o inimigo fixo. Ou seja, que fica no fluxo da tela se deslocando normalmente.

Importante, se você nunca viu nada de spriter no Unity você pode começar vendo esse documento, que traz o principal de como funciona o sistema de sprites no Unity https://docs.google.com/document/d/1wZ3X0q_Xl5EwPc2LMLGsd6Z3bAjIEuaunsSSG89gpZQ/edit?usp=sharing

Para essa fase inicial do projeto vamos colocar o inimigo fixo. Aqui neste exemplo vamos usar uma imagem de baixa resolução de uma pedra. Vamos pegar do Open Game Art (https://opengameart.org/content/a-layered-asteroid-rock). Contudo você pode pegar qualquer sprite que desejar, ou mesmo criar as suas (basta salvar em um formato como png por exemplo).

        Primeiro você precisa trazer os seus arquivos novos para seu projeto, com o arquivo de imagem devidamente carregado, você poderá usar ele como sprite na sua cena. Para isso puxe o objeto da janela de projeto e jogue na cena.

Depois de inserirmos o sprite que acabamos de importar em nossa cena, vamos colocar um BoxCollider2D ao GameObject. A forma mais fácil de fazer isso é pela janela do Inspector, selecionando Add Component e BoxCollider2D. Como vamos querer controlar possíveis intersecções com esse objeto podemos habilitar o Is Trigger. Finalmente vamos aplicar uma Tag nesse objeto com nome de "Inimigos" (provavelmente você vai ter de criar a Tag antes.

Nesse estágio nosso inimigo fixo já está semi pronto, então podemos já criar um Prefab dele, que depois vai simplificar a nossa vida para fazermos diversas cópias desse mesmo objeto e reutilizá-lo.

Para não termos bagunça é conveniente criar uma pasta Prefab, uma estratégia que as pessoas usam é colocar um underscore (_) na frente para aparecer primeiro, assim traga o objeto da janela de cena para a janela do projeto.

Enfim, precisamos modificar o script do player para receber o dano quando colidir com um inimigo. Adicione o código a seguir no PlayerController (que está associado a nave). Quando houver uma colisão da nave com a pedra, a pedra será destruída.

PlayerController.cs

private void OnTriggerEnter2D(Collider2D collision)
{
   
if (collision.CompareTag("Inimigos"))
   {
       Destroy(collision.gameObject);
       TakeDamage();
   }
}

Teste seu código para ver se a pedra é destruída se a nave tocar nela.

02 - Animação por SpriteSheet (Player)

Para nosso jogador vamos utilizar a técnica de Sprite Sheets. Uma Sprite Sheet é basicamente uma imagem que contém as diferentes poses de nosso personagem.

https://drive.google.com/file/d/1gxtkMODjn94PMMMN0tms3OVepCzhZ2i5/view?usp=sharing 

Adaptado de https://craftpix.net

Se quiser, use a imagem acima (link postado) senão você pode buscar um conjunto de imagens para seu jogo, ou mesmo criar suas imagens (cuidado criar imagens dá trabalho e pode demorar)

Assim novamente importe essa imagem para o seu projeto como fez anteriormente. Como neste caso temos uma imagem com diversos sprites, depois de importar a imagem, coloque o Sprite Mode em Multiple, e clique em Sprite Editor.

O Unity irá perguntar se você deseja aplicar as alterações não salvas. Selecione Apply e ele irá abrir o Sprite Editor.

No canto superior esquerdo temos a opção slice, com a seguintes opções:

        Nesse caso vamos utilizar o Grid by Cell Count, visto que o grid é regular e a imagem está dividida em 4 por 4:

Clique em Slice, que sua imagem ficará com algo assim:

Agora cada um desses Elementos definidos pela borda branca, é um sprite individual. Ao clicar em em apply, você conseguirá ver que cada um deles existe em nossa aba projeto.

 Vamos agora fazer as animações.

Selecione Window > Animation > Animation ou se você gostar de Hotkeys Ctrl+6 (Win) ou Cmd+6 (Mac). Selecione na hierarquia o GameObject Player, que é objeto que desejamos animar e na aba animation podemos clicar em Create.

Essa ação irá criar um Animator Controller (que serve para gerenciar todas as animações em um objeto) e adicionar o componente de Animation Clip no objeto em questão.

A primeira animação que faremos é a que chamamos de Idle, essa é a animação de quando nada está acontecendo com o personagem. Costumam ser animações rápidas, para dar um pouco mais de vida para o jogo.

 

Para isso selecione um dos sprites, e arraste para a Timeline do Animation Clip que acabamos de criar.

Além desse sprite vamos mexer um pouco com a movimentação da nave, para não ser apenas uma imagem estática.

Para isso clique no botão para iniciar a gravação de alterações na janela animação (botão vermelho que fica no canto superior esquerdo da janela de animação), isso permitirá que as alterações que fizermos no inspetor fiquem salvas na animação.

Feito isso, garanta que o frame 0 esteja selecionado, e no inspector clique com o botão direito no campo posição (Position) de nosso player e selecione Add Key

Isso adiciona uma propriedade em nossa animação

Mova a posição da animação para outro ponto da linha do tempo, e altere a posição como quiser.

Para finalizar a animação em um loop perfeito, arraste para outro ponto da linha do tempo e coloque os mesmo parâmetros do primeiro frame. (Você pode copiar e colar também se quiser).

Se quiser ajustar o comportamento da interpolação de animação, é possível pelo editor de curvas. Na aba Animation mesmo existe a visualização de curvas basta clicar nela e toda a visualização da animação é alterada.

Mais uma animação

Agora vamos fazer mais uma animação para nosso jogador. Podemos selecionar create new clip, que irá criar uma nova animação vazia. Feito isso selecionamos todos os sprites que precisamos, e arrastamos para a timeline.

Com isso já temos as animações básicas que iremos utilizar em nosso player.

03 - Animator

Na sessão anterior, criamos um gerenciador de animações (que vamos ver agora)  chamado Animator, assim que criamos o primeiro clip de animação para o nosso player. É com esse componente que conseguiremos controlar quais animações devem ocorrer a cada momento de nosso jogo, para cada um de nossos GameObjects.

Assim, abra a janela do Animator em Windows > Animation > Animator.

O Animator, funciona em sua essência como uma máquina de estados, e conseguimos definir as transições entre estados. Experimente um pouco clicando e tocando as animações.

Vamos fazer agora o seguinte: por padrão, se não apertarmos nenhuma tecla vamos deixar a animação idle, e quando clicarmos em algo mudamos para animação da aceleração.

Para poder fazer a troca de animação, precisamos criar algo chamado de transição. Vamos criar uma nova transição para trocar quando a nave se movimentar.

Clique com o botão direito em uma das animações e selecione, MakeTransition, e clique na animação que você quer que seja feita a transição, faça a volta também.

 

Feito isso, conseguimos selecionar as transições e verificar seu comportamento no Inspector, perceba que quando selecionada ele tem a opção de condições a serem satisfeitas para que a transição ocorra.

Mas ainda não temos nenhum parâmetro para inserir nessas condições e assim definir o comportamento, para criá-los, selecione a aba parameters dentro do Animator.

Vamos criar um parâmetro simples para a velocidade de nossa nave, os parâmetros podem ser Float, Int, Boolean ou Trigger. Para serem utilizados da melhor forma para sua animação. Vamos criar um Velocity como float.

Agora em nossas transições podemos definir as condições, clique no símbolo + abaixo da lista de condições.

Neste exemplo, vamos definir a transição do estado Idle para Aceleração sempre que o valor da Velocity for maior que o (Greater 0) e a transição de volta sempre que Velocity for menor que 0.1 (Less 0.1).

Agora em nosso PlayerController precisamos atualizar esse parâmetro do animator.

 

PlayerController.cs

  Animator animator;
   
private void Start()
   {
       animator = GetComponent<Animator>();
   }

   
void FixedUpdate()
   {
       
float yInput = Input.GetAxis("Vertical");
       
float xInput = Input.GetAxis("Horizontal");
       Thrust(xInput, yInput);
       
if (yInput != 0 || xInput != 0)
       {
           animator.SetFloat(
"Velocity", 1.0f);
       }
       
else
       {
           animator.SetFloat(
"Velocity", 0.0f);
       }
   }

Caso a nave não esteja se movimentando mais, verifique se o Apply Root Motion está ativo. Senão ative.

O animator é uma ferramenta poderosa da unity e pode ficar extremamente complicado. Para maiores detalhes veja a documentação

https://docs.unity3d.com/Manual/class-AnimatorController.html

04 - Bone Animation

Para o inimigo volante iremos utilizar a técnica de bone animation (como se fosse um esqueleto), para isso precisaremos que os elementos que formam o personagem estejam em um único arquivo de imagem, essa técnica é muito semelhante ao que se aplica nas animações personagens em 3D.

https://drive.google.com/file/d/1KfNJ8N7KZc7q4e0E3iOuNBTV_q1jgvMJ/view?usp=sharing

Adaptado de https://craftpix.net

Para esta técnica precisamos que todos os componentes que formam o personagem final estejam em uma única imagem, porém separados e com um fundo transparente.

Depois de importar a imagem mantenha em single image, mas abra o sprite editor. Nele iremos criar os Bones necessários para nossa animação.

No menu dropdown do sprite editor clique em Skinning Editor.

Você deve ficar com uma imagem parecida com a seguinte:

Clique em Create Bone, e vamos posicioná-los.

O primeiro é o mais importante, que será o principal do nosso “esqueleto”

Para posicionar um Bone basta clicar no início, e depois no fim dele, o próximo será criado a partir do fim do anterior, para posicionar bones não rígidos, ou em outra hierarquia clique com o botão direito do mouse, selecione o “pai” e posicione o próximo bone.

Criado o esqueleto, renomeie os bones para que seja mais fácil de entender depois, para isso clique em Edit Bone, e selecione um deles. E o renomeie e ajuste sua profundidade, quanto maior, mais “a frente” ele será renderizado.

Renomeie o Primeiro como root, isso é necessário para o script que utilizaremos para gerar os bones no editor.

Feito isso precisamos gerar e ajustar a geometria de nosso sprite, Clique em Auto Geometry, uma caixa de opções irá se abrir no canto inferior direito, ajuste esses valores para o melhor encaixe de geometria, e se necessário faremos os ajustes manualmente.

Ao final do processo clique em Generate For Selected para ver o resultado.

Faça todos os ajustes utilizando as opções de Criar Edges e Vértices, procure ajustar os Vértices o mais próximo possível do seu sprite.

Por fim, no sprite editor ainda precisamos ajustar os pesos de influência que cada bone tem sobre uma parte específica, Isso significa que se aquele bone se movimentar, ele irá deformar de alguma forma as áreas que ele tem influência, para isso selecione cada um dos vértices, e ajuste o slider de influência, ou utilize o pincel de pesos.

Quando acaba as edições no Sprite editor, clique em Apply. Pegue sua nova sprite e jogue para a janela de cena, assim poderá fazer mais ajustes e testes. Posicione seu novo inimigo em algum lugar da cena.

Para de fato usar o sistema de esqueletos vamos precisar de um novo componente no objeto. Assim adicione o componente SpriteSkinn ao GameObject que acabou de ser criado.

Clique em Create Bones, se você definiu o nome do primeiro que você criou como root, não deve ter maiores problemas.

Agora precisamos fazer os ajustes necessários para montá-lo em cena, agora você consegue selecionar os bones como qualquer outro objeto e movimentá-los pela cena.

E por fim podemos animar nosso esqueleto, para isso crie uma nova animação para o sprite que acabamos de inserir em nossa cena, clique no botão de gravar e faça os ajustes necessários para sua animação.

Inimigo Volante

Com a animação de nosso inimigo criado vamos criar o comportamento dele como está descrito no documento de requisitos.

Para fazer isso, vamos criar um script VolantBehaviour, e fazer este script herdar de SteerableBehaviour. Tomamos essa opção para que seja mais fácil dar manutenção em nosso código enquanto ele cresce e o projeto fica mais complexo.

VolantBehaviour.cs

public class VolantBehaviour : SteerableBehaviour
{

}


Vamos dar uma olhadinha classe base:

SteerableBehaviour.cs (Essa já está no pacote inicial)

[RequireComponent(typeof(Rigidbody2D))]
[
RequireComponent(typeof(BoxCollider2D))]
public abstract class SteerableBehaviour : MonoBehaviour
{
   
public ThrustData td;

   
private Rigidbody2D rb;

   
private void Awake()
   {
       
if (td == null)
       {
           
throw new MissingReferenceException($"ThrustData not set in {gameObject.name}'s Inspector");
           
       }
       rb = GetComponent<Rigidbody2D>();
   }

   
protected virtual void Thrust(float x, float y)
   {
       rb.MovePosition(rb.position +
new Vector2(x * td.thrustIntensity.x, y * td.thrustIntensity.y) * Time.fixedDeltaTime);
   }

}

Tem muita coisa acontecendo como a classe base, vamos passar, passo a passo o que está acontecendo.

As duas primeiras linhas, em que temos:

[RequireComponent(typeof(Rigidbody2D))]
[
RequireComponent(typeof(BoxCollider2D))]

São atributos de classe específicos da Unity, e eles funcionam para facilitar a nossa vida de diversas formas, nesse caso, RequireComponent, assim que atribuirmos esse script ao nosso GameObject, caso esses componentes não estejam presentes, eles serão adicionados automaticamente, evitando erros futuramente.

Seguindo o código, temos uma propriedade pública da nossa classe do tipo ThrustData, que é específico de nosso programa, e é um ScriptableObject.

ScriptableObjects são Objetos especiais na Unity que funcionam como objetos de dados, que podem ser criados e alterados pela interface, facilitando o trabalho daqueles que não tem tanta facilidade com código.

Também são ótimas opções para quando queremos que diversos elementos de nosso jogo compartilhem uma mesma informação, e dessa forma temos esses dados em um lugar centralizado e não espalhado pelo código, ou seja, os ScriptableObjects são instâncias de classes feitas pela interface do Unity.

Para criar um ScriptableObject para nosso inimigo clique com o botão direito na aba projeto, create > ScriptableObjects > ThrustData. No pacote inicial já deixamos um criado, chamado de PlayerShipData. Crie um para o seu inimigo móvel, por exemplo chamaremos ele de ThrustEnemy.

O objeto criado é extremamente simples e tem apenas uma propriedade que é um Vetor 2D para que possamos definir as velocidades em X e Y dele.

Caso queira criar outros ScriptableObjects para seu jogo, observe o script ThrustData.cs em ScriptableObjects > Definitions

Na função Awake da SteerableBehavior, verificamos se no inspector foi atribuído o ScriptableObject.

Por fim, temos só mais um método no SteerableBehavior que é o virtual Thrust(x, y), o que significa que podemos sobrescrevê-la (override) se precisarmos que seja diferente. Essa função apenas aplica a intensidade do impulso, na direção correta.

Para fazer nosso inimigo se mover então precisamos calcular as intensidades verticais e horizontais que precisam ser aplicadas. Neste momento vamos criar um inimigo simples que tenha apenas um movimento vertical.

Para isso vamos utilizar funções matemáticas periódicas, e a mais fácil delas são as trigonométricas.

VolantBehaviour.cs

public class VolantBehaviour : SteerableBehaviour
{
   
float angle = 0;

   
private void FixedUpdate()
   {
       angle +=
0.1f;
       
if (angle > 2.0f * Mathf.PI) angle = 0.0f;
       Thrust(
0, Mathf.Cos(angle));
   }
}

Vamos fechar tudo então. Primeiro coloque o script VolantBehavior com o Add Component no inimigo volante que criou no passo anterior.

Ao fazer isso o campo Td, que é justamente do ScriptableObject ThrustData está vazio, assim é só puxar da janela de projeto o ScriptableObject (TrustData) que você criou para essa campo no Inspector.

Pronto, agora você pode definir a velocidade máxima direto pela interface do Unity em um objeto. Clique nele e veja que existem alguns campos para preencher. Coloque alguma velocidade em Y, para assim definir a velocidade máxima desse objeto.

Você pode alterar o RigidBody para Kinematic, e ativar o Is Trigger do BoxCollider2D.

Por fim, não podemos esquecer de colocar a Tag de "Inimigos", neste GameObject, e transformá-lo em em um prefab.

        05 - Montando a cena.

Agora com os prefabs que você criou fica fácil montar uma cena para seu personagem percorrer, basta arrastar os prefabs na posição que você quiser.


Questões de Estudo

  1. Quais os modos de importação de sprites e quais suas diferenças?
  2. O que significa o número da propriedade Pixels Per Unit na importação de sprites?
  3. Qual a função do Animator?
  4. Qual a diferença entre Update e Fixed Update?
  5. O que são Scriptable Objects?
  6. Qual a função da pintura de pesos dos bones?

Leitura Complementar