Introdução a criação de jogos 2D com libGDX
Não me considero um gamer, bem longe disso para ser sincero. Ainda assim, em algum momento da carreira, todo desenvolvedor acaba sentindo curiosidade em criar um pequeno jogo, nem que seja apenas para entender como esse universo funciona. É justamente esse o objetivo aqui: realizar uma pequena introdução na criação de jogos utilizando Java com a libGDX.
A libGDX é um framework que permite criar jogos 2D (e até 3D) de forma multiplataforma. Com ela, a mesma base de código pode ser executada em desktop, Android, iOS e web, sem a necessidade de reescrever toda a lógica para cada ambiente.
Ao longo deste tutorial, vamos desenvolver um Space Shooter simples. A nave se movimenta, dispara tiros, inimigos surgem na tela, colisões são detectadas entre tiros e inimigos e, ao final, você terá compreendido os conceitos fundamentais do desenvolvimento de jogos 2D, além de contar com um protótipo funcional de um joguinho.
Um pouco de planejamento antes de começar
Antes de começar a codificar, vale a pena fazer um pequeno planejamento. Definir os elementos do jogo desde o início ajuda a manter o projeto organizado e facilita a implementação das mecânicas principais. Para este Space Shooter, teremos os seguintes componentes:
- Jogador (nave do jogador): A nave do jogador poderá se mover tanto na vertical quanto na horizontal. O controle será feito, pelas setas do teclado. Além disso, ao pressionar a barra de espaço, a nave dispara um projétil em direção aos inimigos.
- Projéteis (tiros da nave): Sempre que o jogador pressionar a barra de espaço, um projétil será criado e passará a se mover para o lado direito da tela, região por onde os inimigos surgem. Caso um projétil colida com um inimigo, este será eliminado.
- Inimigos: As naves inimigas aparecerão periodicamente no lado direito da tela e se moverão em direção ao lado esquerdo. O jogo termina se um inimigo colidir com a nave do jogador ou alcançar o limite esquerdo da tela.
- Pontuação: A cada inimigo eliminado, a pontuação do jogador será incrementada em 1 pontos. O valor atual da pontuação será exibido no canto superior esquerdo da tela.
- Estados do jogo
O jogo será organizado em três estados principais:- INÍCIO: exibe a mensagem de iniciar, aguardando a ação do jogador para começar a partida.
- JOGANDO: estado principal do jogo, no qual a nave se movimenta, inimigos surgem e colisões são processadas.
- GAME OVER: acionado quando um inimigo colide com a nave do jogador ou alcança o lado esquerdo da tela; uma mensagem de “Game Over” é exibida juntamente com a pontuação final.
Criando o projeto
Para criar o projeto, o primeiro passo é acessar o site oficial da libGDX e baixar a ferramenta libGDX Project Setup Tool, também conhecida como gdx-liftoff. Utilizaremos a versão em JAR, que pode ser executada diretamente em qualquer ambiente com Java instalado.
Com o arquivo baixado, basta executá-lo para iniciar o processo de configuração do projeto, onde definiremos as principais opções da aplicação antes de começar a codificar.
java -jar gdx-liftoff-1.14.0.2.jar

No primeiro momento, preenchemos os campos básicos do projeto, como o nome da aplicação, o pacote base e o nome da classe principal. Em seguida, avançamos para as configurações adicionais clicando em Project Options.

Na próxima etapa, selecionamos as plataformas que farão parte do projeto. Para este tutorial, utilizaremos apenas os módulos core, desktop e html. O módulo core concentra toda a lógica principal do jogo, enquanto desktop e html adicionam o suporte necessário para executar a aplicação nessas plataformas. Também seria possível incluir o módulo android, mas, para simplificar o projeto inicial, vamos manter apenas essas três opções.
Em Extensions, selecionamos o FreeType, que oferece um suporte mais flexível e eficiente para o trabalho com fontes. Com tudo configurado, clicamos em Next para avançar.

Na tela de Third-Party, podemos selecionar bibliotecas adicionais que auxiliam no desenvolvimento do jogo. Para este projeto, não utilizaremos nenhuma dependência extra, portanto mantemos as opções padrão e seguimos adiante clicando em Next.

Na tela de Settings, definimos a versão do Java e o diretório onde o projeto será gerado. Aqui configuramos a versão 11 do Java para ter compatibilidade com a plataforma html, se criarmos um projeto somente desktop podemos utilizar uma versão maior do Java. Com essas opções configuradas, clicamos em Generate para que o gdx-liftoff crie a estrutura completa do projeto na pasta selecionada.
Após a geração, o projeto pode ser aberto normalmente na IDE de sua preferência, lembrando que se trata de um projeto Gradle. Para testar rapidamente se tudo está funcionando, basta acessar o diretório do projeto e executar, via terminal, o script gradlew.bat no Windows ou ./gradlew no Linux. Esse arquivo é criado automaticamente junto com o projeto e é responsável por inicializar o build. O comando run irá executar o jogo na plataforma desktop.
gradle.bat run

Agora, vamos explorar a estrutura de pastas do projeto gerado. Isso nos ajudará a entender como o código está organizado e como os diferentes módulos e recursos são distribuídos dentro do diretório.

A pasta core é onde fica toda a lógica principal do jogo. É nela que escreveremos a maior parte do código, incluindo movimentação, renderização, colisões e regras do jogo. Esse módulo é compartilhado entre todas as plataformas selecionadas no setup.
As pastas html e lwjgl3 correspondem às plataformas de execução escolhidas durante a criação do projeto. Cada uma delas contém apenas código específico da plataforma, funcionando basicamente como launchers. Caso a plataforma Android tivesse sido selecionada no setup, uma pasta adicional chamada android também estaria presente.
Em geral, só precisamos alterar as classes desses módulos quando for necessário fazer configurações específicas de plataforma, já que toda a lógica do jogo permanece no módulo core. Por exemplo, para ajustar o tamanho da janela no desktop, podemos editar o arquivo: lwjgl3/src/main/java/com/example/lwjgl3/Lwjgl3Launcher.java
Nesse arquivo, basta alterar a chamada do método setWindowedMode, ajustando o tamanho da janela (por exemplo, para 1280×680). Da mesma forma, é nesse launcher que configuramos o ícone da janela, utilizando o método setWindowIcon.
Por fim, temos a pasta assets, que armazena todos os recursos do jogo, como imagens/texturas, fontes e sons.
Com a estrutura compreendida, vamos analisar a classe principal do jogo, gerada automaticamente pelo setup e localizada em: core/src/main/java/com/example/SpaceShooter.java
public class SpaceShooter extends ApplicationAdapter {
@Override
public void create() {
}
@Override
public void render() {
}
@Override
public void dispose() {
}
}
Como podemos observar, a classe SpaceShooter herda de ApplicationAdapter e sobrescreve três métodos fundamentais do ciclo de vida de uma aplicação libGDX:
create(): Esse método é chamado uma única vez, logo após o jogo ser iniciado. Ele funciona de forma semelhante a um construtor e é o local adequado para realizar a inicialização e alocação de recursos, como texturas, fontes, sons e objetos principais do jogo.render(): Este é o método executado continuamente dentro do game loop e pode ser considerado o coração do jogo. Ele é chamado, em geral, cerca de 60 vezes por segundo, sendo responsável por:- Atualizar a lógica do jogo;
- Movimentar jogador e inimigos;
- Verificar colisões;
- Renderizar os elementos visuais na tela.
dispose(): Esse método é invocado quando o jogo é encerrado. Nele, liberamos todos os recursos alocados anteriormente, como texturas e sons criados no métodocreate(), evitando vazamentos de memória.
Além desses, ainda é possível sobrescrever outros métodos, como resize(), pause() e resume(), que fazem parte do ciclo de vida de uma aplicação libGDX e permitem reagir a eventos como redimensionamento da janela ou pausa da aplicação.
Desenhando na tela com a libGDX
Vamos agora partir para o que realmente aparece para o jogador: o desenho na tela. Como mencionado anteriormente, toda a parte gráfica do jogo é renderizada dentro do método render(), que é chamado continuamente pelo game loop, cerca de 60 vezes por segundo. Cada execução desse método representa o desenho de um frame do jogo.
Antes de desenhar qualquer elemento, é uma boa prática limpar a tela, removendo o frame anterior. Isso é feito utilizando o método ScreenUtils.clear(Color), passando uma cor de fundo, garantindo que o próximo frame seja desenhado sobre uma tela limpa.
Para desenhar formas básicas, utilizamos a classe ShapeRenderer. Essa classe deve ser instanciada no método create() e utilizada dentro do render(). O fluxo de uso é simples: iniciamos o processo de desenho chamando o método begin(), em seguida desenhamos as formas desejadas (retângulos, círculos, linhas, etc.) e finalizamos com a chamada ao método end().
public class SpaceShooter extends ApplicationAdapter {
private ShapeRenderer shapeRender;
@Override
public void create() {
this.shapeRender = new ShapeRenderer();
}
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
shapeRender.begin(ShapeType.Line);
shapeRender.setColor(Color.WHITE);
shapeRender.rect(200, 250, 100, 100);
shapeRender.setColor(Color.BLUE);
shapeRender.circle(400, 400, 100);
shapeRender.end();
}
@Override
public void dispose() {
}
}
Com o código pronto, executamos novamente o projeto utilizando o comando gradlew.bat run para visualizar o resultado na tela e confirmar se os elementos gráficos estão sendo renderizados corretamente.

Obs.: As linhas pontilhadas e os números presentes na imagem são apenas ilustrativos, servindo para indicar as posições e dimensões dos objetos na tela.
No código anterior, começamos o processo de desenho chamando o método begin(), que recebe como parâmetro um valor do enum ShapeType, indicando se a forma será preenchida (Filled) ou apenas o contorno (Line). Em seguida, utilizamos o método setColor() para definir a cor da forma a ser desenhada. Para desenhar um retângulo, chamamos o método rect(), que recebe quatro parâmetros: os dois primeiros correspondem à posição (x, y) da forma na tela. Vale lembrar que o eixo Y começa na parte inferior da tela, portanto, o ponto (0, 0) está localizado no canto inferior esquerdo. Os dois parâmetros seguintes são a largura e a altura do retângulo.
Para desenhar um círculo, usamos o método circle(), que aceita três parâmetros: os dois primeiros definem a posição (x, y) e o terceiro define o raio do círculo.
A classe ShapeRenderer oferece diversos outros métodos para desenhar formas, como line(x1, y1, x2, y2) para desenhar linhas e ellipse(x, y, w, h) para desenhar elipses.
Trabalhando com imagens
Em jogos reais, os gráficos não são construídos a partir de formas geométricas simples, mas sim de imagens que são carregadas do disco, decodificadas e enviadas para a GPU. Na libGDX, o carregamento de imagens é feito por meio da classe Texture, que recebe como parâmetro o nome do arquivo presente na pasta assets do projeto. Como qualquer recurso gráfico, a criação da Texture deve acontecer no método create(), e a liberação de memória deve ser feita no método dispose(), chamando o método dispose() da própria textura.
Como um único frame de jogo normalmente é composto por várias imagens, enviá-las individualmente para a GPU pode se tornar ineficiente. Para resolver isso, a libGDX fornece a classe SpriteBatch, que permite desenhar múltiplas texturas de forma otimizada em um único lote (batch). O SpriteBatch também deve ser instanciado no método create(). Já no método render(), iniciamos o processo de desenho chamando begin(), desenhamos todas as imagens do frame usando o método draw() e, por fim, encerramos o lote chamando end().
Com esses conceitos em mãos, vamos começar desenhando o fundo do jogo. Para isso, faça o download da imagem espaco.png e coloque-a na pasta assets do projeto.
public class SpaceShooter extends ApplicationAdapter {
private SpriteBatch batch;
private Texture fundo;
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
}
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.batch.end();
}
@Override
public void dispose() {
this.fundo.dispose();
}
}
O método draw() da classe SpriteBatch é sobrecarregado e oferece diversas variações, permitindo desenhar texturas de maneiras diferentes, conforme a necessidade do jogo. A seguir, estão algumas das formas mais comuns de utilização:
draw(Texture texture, float x, float y): Desenha a textura a partir da posição (x, y), utilizando a largura e altura originais da imagem.draw(Texture texture, float x, float y, float width, float height): Desenha a textura a partir da posição (x, y), utilizando a largura(width) e altura(height) informadas. Essa variação permite redimensionar a imagem durante o desenho.draw(Texture texture, float x, float y, float width, float height, int srcX, int srcY, int srcWidth, int srcHeight, boolean flipX, boolean flipY): Desenha a textura a partir da posição (x, y) com a largura width e altura height, recortando apenas uma região específica da imagem original. O recorte começa no ponto (srcX, srcY) e utiliza as dimensões srcWidth e srcHeight. Vale destacar que os pontos srcX e srcY têm como referência o canto superior esquerdo da textura, enquanto os valores x e y utilizam como base o canto inferior esquerdo da tela. Os parâmetros flipX e flipY permitem inverter a textura horizontalmente e verticalmente.

Vamos agora implementar a nave do jogador. Como ela será responsável por controlar posição, movimentação, disparo e colisão, faz sentido encapsular esse comportamento em uma classe específica. Essa classe contará com alguns métodos essenciais:
- Construtor: responsável por inicializar os recursos necessários, como texturas.
- desenhar(): encarregado de desenhar a nave na tela.
- atualizar(): utilizado para ler as entradas do teclado e atualizar a posição da nave conforme a interação do jogador.
- dispose(): responsável por liberar os recursos alocados.
Antes de continuar, faça o download da imagem da nave e salve-a na pasta assets do projeto. Em seguida, vamos analisar o código da classe NaveJogador, inicialmente focando apenas na lógica de renderização da nave.
public class NaveJogador {
private Texture tNave;
private Sprite sNave;
private float x, y;
public NaveJogador(){
this.tNave = new Texture("nave.png");
this.sNave = new Sprite(tNave);
this.x = 20;
this.y = Gdx.graphics.getHeight() / 2 - tNave.getHeight() / 2;
}
public void desenhar(SpriteBatch batch){
sNave.setPosition(x, y);
sNave.draw(batch);
}
public void atualizar(){
}
public void dispose(){
tNave.dispose();
}
}
Na classe NaveJogador, temos inicialmente alguns atributos responsáveis por representar e controlar a nave do jogador. O primeiro deles é a Texture tNave, que corresponde à imagem da nave que será desenhada na tela. Em seguida, temos o Sprite sNave. O Sprite é uma abstração bastante útil na libGDX, pois encapsula não apenas a textura, mas também informações geométricas como posição (x, y), largura, altura, rotação e escala, facilitando o gerenciamento do objeto que será renderizado. Além disso, utilizamos dois valores do tipo float, x e y, para armazenar a posição atual da nave na tela.
No construtor, carregamos a textura informando o nome do arquivo presente na pasta assets. Em seguida, criamos o Sprite a partir dessa textura e inicializamos a posição da nave. O valor de x é definido como 20 para evitar que a nave fique colada ao canto esquerdo da tela. Já a posição y é calculada de forma que a nave comece centralizada verticalmente: recuperamos a altura da tela com Gdx.graphics.getHeight(), dividimos esse valor por dois e subtraímos metade da altura da textura da nave. Dessa forma, garantimos que o centro da nave fique alinhado ao centro da tela, e não apenas sua base.
No método desenhar, recebemos um SpriteBatch como parâmetro, atualizamos a posição do Sprite com os valores atuais de x e y e, em seguida, chamamos o método draw, que efetivamente renderiza a nave na tela.
Por fim, no método dispose, liberamos o recurso associado à textura da nave, evitando desperdício de memória e possíveis memory leaks.
Com isso concluído, podemos avançar para a atualização do código da classe principal do jogo SpaceShooter, onde integraremos a nave ao ciclo principal do jogo.
public class SpaceShooter extends ApplicationAdapter {
private SpriteBatch batch;
private Texture fundo;
private NaveJogador naveJogador; // adicionado
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
this.naveJogador = new NaveJogador(); // adicionado
}
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
this.naveJogador.atualizar(); // adicionado
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.naveJogador.desenhar(this.batch); // adicionado
batch.end();
}
@Override
public void dispose() {
this.fundo.dispose();
this.naveJogador.dispose();
}
}
Aqui declaramos o atributo NaveJogador e o instanciamos no método create(). Vale lembrar que, no construtor da classe NaveJogador, ocorre a alocação de recursos como texturas; por isso, a instanciação deve ser feita dentro do método create(), que é o ponto adequado do ciclo de vida da aplicação para esse tipo de operação.
No método render(), começamos chamando o método atualizar() da NaveJogador antes de iniciar o desenho na tela. Mais adiante, esse método será responsável por concentrar a lógica de movimentação e demais atualizações da nave. Em seguida, após desenhar o fundo do jogo, chamamos o método desenhar() da NaveJogador. Essa ordem é importante, pois o fundo deve ser renderizado primeiro; caso contrário, ele acabaria “encobrindo” a imagem da nave.
Por fim, no método dispose(), chamamos o método dispose() da NaveJogador, garantindo que os recursos alocados sejam liberados corretamente ao encerrar o jogo.

Para movimentar a nave, precisamos verificar se alguma das teclas de direção está sendo pressionada e, com base nisso, atualizar as posições x e y. Quando a tecla RIGHT estiver pressionada, incrementamos a posição x da nave; ao pressionar LEFT, decrementamos esse valor. O mesmo raciocínio se aplica ao eixo vertical: UP incrementa a posição y e DOWN decrementa. Toda essa lógica ficará concentrada no método atualizar() da classe NaveJogador.
É importante observar que o método atualizar() é chamado dentro do método render() da classe SpaceShooter, antes do desenho da nave. Como o método render() é executado a cada frame do jogo, manter uma tecla pressionada faz com que a posição da nave seja ajustada frame a frame. Por exemplo, ao pressionarmos a tecla RIGHT, o método atualizar() detecta a entrada e incrementa o valor de x. Em seguida, o método desenhar() é executado, exibindo a nave em uma nova posição. Quando o próximo frame é processado e a tecla continua pressionada, esse ciclo se repete, movendo a nave gradualmente para a direita. Esse processo contínuo, frame a frame é o que gera a sensação de movimento e animação na tela.
Para detectar as entradas do teclado, utilizamos o método isKeyPressed() do objeto Gdx.input, que fornece suporte a diferentes tipos de entrada, como teclado, mouse e, em ambientes móveis, sensores como o acelerômetro. O método isKeyPressed() recebe como parâmetro uma constante da classe Input.Keys, indicando qual tecla desejamos verificar.
Outro ponto fundamental é garantir que a nave não ultrapasse os limites da tela. Para isso, antes de atualizar qualquer posição, devemos validar se o novo valor permanece dentro da área visível. Por exemplo, antes de incrementar a posição vertical, verificamos se a posição y, somada à altura da nave, não ultrapassa a altura total da tela, obtida por meio de Gdx.graphics.getHeight(). Essa verificação é necessária porque, ao desenhar sprites na libGDX, o ponto de referência utilizado é o canto inferior esquerdo da imagem.
public class NaveJogador {
//...
public void atualizar(){
if(Gdx.input.isKeyPressed(Input.Keys.UP) && this.y + sNave.getHeight() < Gdx.graphics.getHeight() ){
this.y += 10;
}
if(Gdx.input.isKeyPressed(Input.Keys.DOWN) && this.y > 0){
this.y -= 10;
}
if(Gdx.input.isKeyPressed(Input.Keys.LEFT) && this.x > 0){
this.x -= 10;
}
if(Gdx.input.isKeyPressed(Input.Keys.RIGHT) && this.x + sNave.getWidth() < Gdx.graphics.getWidth() ){
this.x += 10;
}
}
//...
}
O míssil: que comece a destruição!
Como vimos anteriormente, a nave deve disparar um projétil, no caso um míssil, quando o jogador pressionar a barra de espaço. Para representar esse míssil, vamos criar uma classe Missil, com uma estrutura bastante semelhante à da NaveJogador.
Na classe Missil, teremos um construtor responsável por inicializar a Texture e o Sprite. Nesse momento, não é necessário definir as posições iniciais de x e y, pois elas só serão atribuídas quando o míssil for efetivamente lançado. Essas informações ficarão armazenadas diretamente no próprio objeto Sprite.
Outro atributo importante dessa classe é o booleano foiLancado, que será utilizado para indicar se o míssil deve ou não ser desenhado na tela. No método desenhar(), recebemos um SpriteBatch e, caso o valor de foiLancado seja verdadeiro, desenhamos o sprite do míssil. Por enquanto, o método atualizar() permanecerá vazio, e no método dispose() liberamos o recurso associado à textura.
Além disso, criamos um método de consulta que informa se o míssil já foi lançado. O método lancar() será responsável por alterar o estado de foiLancado e definir a posição x e y do sprite, fazendo com que o míssil passe a ser exibido na tela a partir da posição informada, que será a posição atual da nave do jogador.
Antes irmos para o código da classe Missil, faça o download da imagem e salve-a na pasta assets do projeto.
public class Missil {
private Texture tMissil;
private Sprite sMissil;
private boolean foiLancado = false;
public Missil(){
this.tMissil = new Texture("missel.png");
this.sMissil = new Sprite(tMissil);
}
public void desenhar(SpriteBatch batch){
if(this.foiLancado){
sMissil.draw(batch);
}
}
public void atualizar(){
}
public void lancar(float x, float y){
sMissil.setPosition(x, y);
this.foiLancado = true;
}
public void dispose(){
tMissil.dispose();
}
public boolean foiLancado() {
return this.foiLancado;
}
}
Agora, instanciamos um objeto Missil no método create() da classe principal do jogo. Em seguida, chamamos os métodos atualizar() e desenhar() dentro do método render(), garantindo que o míssil seja atualizado e desenhado a cada frame quando necessário. Por fim, no método dispose() da classe do jogo, chamamos o método dispose() do míssil para liberar corretamente os recursos alocados.
public class SpaceShooter extends ApplicationAdapter {
private SpriteBatch batch;
private Texture fundo;
private NaveJogador naveJogador;
private Missil missil; // adicionado
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
this.missil = new Missil();// adicionado
this.naveJogador = new NaveJogador(this.missil); // alterado
}
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
this.naveJogador.atualizar();
this.missil.atualizar(); // adicionado
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.missil.desenhar(this.batch); // adicionado
this.naveJogador.desenhar(this.batch);
this.batch.end();
}
@Override
public void dispose() {
this.fundo.dispose();
this.naveJogador.dispose();
this.missil.dispose();// adicionado
}
}
Como podemos observar no método create(), após instanciarmos o objeto Missil, ele é passado como parâmetro para o construtor da classe NaveJogador. Fazemos isso, pois é a própria nave que deve ser responsável por disparar o míssil. Dessa forma, quando a tecla de espaço for pressionada, a nave poderá chamar o método lancar() do míssil, informando a posição atual da nave para que o disparo ocorra no local correto.
public class NaveJogador {
private Texture tNave;
private Sprite sNave;
private Missil missil; // adicionado
private float x, y;
public NaveJogador(Missil missil){ // alterado
this.tNave = new Texture("nave.png");
this.sNave = new Sprite(tNave);
this.missil = missil; // adicionado
this.x = 20;
this.y = Gdx.graphics.getHeight() / 2 - tNave.getHeight() / 2;
}
public void desenhar(SpriteBatch batch){
sNave.setPosition(x, y);
sNave.draw(batch);
}
public void atualizar(){
if(Gdx.input.isKeyPressed(Input.Keys.UP) && this.y + sNave.getHeight() < Gdx.graphics.getHeight() ){
this.y += 10;
}
if(Gdx.input.isKeyPressed(Input.Keys.DOWN) && this.y > 0){
this.y -= 10;
}
if(Gdx.input.isKeyPressed(Input.Keys.LEFT) && this.x > 0){
this.x -= 10;
}
if(Gdx.input.isKeyPressed(Input.Keys.RIGHT) && this.x + sNave.getWidth() < Gdx.graphics.getWidth() ){
this.x += 10;
}
// adicionado
if(Gdx.input.isKeyPressed(Input.Keys.SPACE) && !this.missil.foiLancado()){
this.missil.lancar(this.x, this.y);
}
}
public void dispose(){
tNave.dispose();
}
}
No método atualizar(), verificamos se a tecla de espaço foi pressionada e se o míssil ainda não foi lançado. Essa validação é importante para evitar que vários mísseis sejam disparados ao mesmo tempo, neste jogo, permitiremos apenas um míssil ativo por vez. Um novo disparo só será possível quando o míssil sair da tela ou atingir um inimigo, momento em que o estado foiLancado deverá ser redefinido para false.
Se executarmos o jogo neste ponto, perceberemos que o míssil é desenhado, mas não se movimenta. Isso acontece porque ainda não estamos atualizando sua posição no método atualizar() da classe Missil. No próximo passo, vamos implementar essa lógica de movimento.
public class Missil {
// ...
public void atualizar(){
float deltaTime = Gdx.graphics.getDeltaTime();
float velocidade = 600;
sMissil.translateX(velocidade * deltaTime);
if(sMissil.getX() > Gdx.graphics.getWidth()){
this.foiLancado = false;
}
}
// ...
}
Neste ponto, precisamos criar a animação automática do míssil, fazendo com que ele saia da sua posição inicial e se desloque continuamente para a direita da tela. Vale lembrar que o método atualizar() é chamado diversas vezes por segundo dentro do método render(), normalmente em torno de 60 vezes por segundo, portanto o deslocamento do míssil deve ser calculado com base no tempo decorrido entre um frame e outro.
Para isso, utilizamos o deltaTime, que representa o tempo, em segundos, desde o último frame renderizado. Em um jogo rodando a aproximadamente 60 FPS, esse valor gira em torno de 0,016. Ao multiplicar o deltaTime pela velocidade desejada do míssil (no nosso caso, 600 pixels por segundo), obtemos o quanto ele deve se mover em cada frame(~10px), garantindo um movimento suave e independente da taxa de quadros. Esse deslocamento é aplicado chamando o método translateX() do Sprite, que incrementa internamente a posição horizontal do míssil.
Após atualizar a posição, verificamos se o míssil já ultrapassou o limite direito da tela. Caso isso aconteça, alteramos o estado do atributo foiLancado para false, fazendo com que ele deixe de ser desenhado e permitindo que um novo míssil possa ser lançado.
Inimigos!
Agora que já temos a nave do jogador e um míssil funcional, é hora de adicionar inimigos ao jogo. Eles surgirão no lado direito da tela, em posições verticais aleatórias, e se moverão continuamente em direção ao lado esquerdo, criando o desafio principal do Space Shooter.
Para dar um pouco mais de variedade visual, utilizaremos diferentes tipos de inimigos. Em vez de carregar várias imagens separadas, vamos trabalhar com uma única imagem contendo várias naves inimigas, um sprite sheet. A partir dessa textura única, recortamos regiões específicas para representar cada inimigo individualmente. Esse recorte é feito por meio da classe TextureRegion, que recebe como parâmetros a Texture, as coordenadas x e y do ponto inicial do recorte, além da largura e altura da região desejada. Vale lembrar que, em uma textura, o ponto (0, 0) fica no canto superior esquerdo da imagem.
Por exemplo, se quisermos recortar uma nave inimiga a partir da posição x = 128 e y = 100, com 128 pixels de largura e 100 pixels de altura, basta criar um TextureRegion informando esses valores.
Texture texture = new Texture("naves-inimigas.png");
TextureRegion tr = new TextureRegion(texture, 128, 100, 128, 100);

Para representar os inimigos, vamos criar uma classe Inimigo responsável por encapsular o comportamento e os dados de cada nave adversária. Antes disso, faça o download da imagem contendo as naves inimigas e salve o arquivo na pasta assets do projeto, pois ela será utilizada como base para a criação dos inimigos.
public class Inimigo {
private Sprite sprite;
public Inimigo(TextureRegion texture){
this.sprite = new Sprite(texture);
float x = Gdx.graphics.getWidth();
float y = MathUtils.random(0, Gdx.graphics.getHeight() - this.sprite.getHeight() );
this.sprite.setPosition(x, y);
}
public void desenhar(SpriteBatch batch){
this.sprite.draw(batch);
}
public void atualizar(){
this.sprite.translateX(-300 * Gdx.graphics.getDeltaTime());
}
}
A classe Inimigo segue uma estrutura muito parecida com as classes NaveJogador e Missil. No construtor, recebemos um TextureRegion, que representa a imagem da nave inimiga, e a partir dele criamos um Sprite. Em seguida, definimos a posição inicial do inimigo: no eixo X, utilizamos a largura da tela para que ele surja fora da área visível, entrando em cena à medida que se move para a esquerda. Já no eixo Y, usamos o método MathUtils.random() para sortear uma posição vertical aleatória entre 0 e a altura da tela menos a altura do sprite, garantindo que o inimigo não apareça fora dos limites superiores da tela.
O método desenhar() apenas renderiza o sprite, sem nenhuma particularidade adicional. No método atualizar(), aplicamos um translateX() com valor negativo, fazendo com que o inimigo se mova da direita para a esquerda, de forma semelhante ao movimento do míssil, porém em sentido oposto.
Como o jogo contará com vários inimigos simultaneamente e novos inimigos precisam surgir periodicamente para manter o desafio, o próximo passo será criar uma classe responsável por gerenciar os inimigos, centralizando sua criação, atualização e remoção.
public class InimigoManager {
private Texture texture;
private Array<Inimigo> inimigos;
private long ultimoInimigo = 0;
private long tempoIntervalorGerar = 3000;
public InimigoManager(){
texture = new Texture("naves-inimigas.png");
this.inimigos = new Array<>();
this.inimigos.add( this.gerarInimigo());
}
private Inimigo gerarInimigo(){
int x = MathUtils.random(0, 1) * 128;
int y = MathUtils.random(0, 2) * 100;
TextureRegion tr = new TextureRegion(this.texture, x, y, 128, 100);
this.ultimoInimigo = TimeUtils.millis();
return new Inimigo(tr);
}
public void atualizar(){
if(TimeUtils.millis() - this.ultimoInimigo > tempoIntervalorGerar){
this.inimigos.add( this.gerarInimigo());
if(tempoIntervalorGerar > 500){
tempoIntervalorGerar -= 100;
}
}
for(Inimigo inimigo : this.inimigos){
inimigo.atualizar();
}
}
public void desenhar(SpriteBatch batch){
for(Inimigo inimigo : this.inimigos){
inimigo.desenhar(batch);
}
}
public void dispose(){
this.texture.dispose();
}
}
No construtor da classe InimigoManager, começamos instanciando a Texture que contém as imagens das naves inimigas (um único arquivo com seis variações). Em seguida, criamos o Array que armazenará os inimigos ativos na tela. Aqui utilizamos a implementação de Array fornecida pela própria libGDX, que é mais adequada e performática para cenários de jogos. Por fim, já adicionamos um primeiro inimigo ao array por meio do método gerarInimigo().
O método gerarInimigo() é responsável por criar novos inimigos de forma dinâmica. Como a imagem das naves inimigas está organizada em duas colunas de 128 px e três linhas de 100 px, utilizamos o MathUtils.random() para sortear qual região da textura será usada. Primeiro, sorteamos um valor entre 0 e 1 e multiplicamos por 128 para obter a coordenada x inicial do recorte. Em seguida, sorteamos um valor entre 0 e 2 e multiplicamos por 100 para obter a coordenada y inicial. Com essas informações, criamos um TextureRegion, que é então passado para o construtor da classe Inimigo. Também registramos o instante de criação do inimigo no atributo ultimoInimigo, utilizando o método TimeUtils.millis(), valor que será usado para controlar o intervalo de geração dos próximos inimigos.
O método desenhar() é simples: percorremos todos os inimigos armazenados no array e chamamos o método desenhar() de cada um deles.
Já no método atualizar(), começamos verificando se o tempo decorrido desde a criação do último inimigo (tempo atual menos ultimoInimigo) é maior que o intervalo definido em tempoIntervalorGerar. Caso seja, um novo inimigo é gerado e adicionado ao array. Para aumentar gradualmente a dificuldade do jogo, reduzimos esse intervalo em 100 ms, até atingir o limite mínimo de 500 ms. Por fim, iteramos sobre todos os inimigos existentes e chamamos seus respectivos métodos atualizar(), garantindo que todos se movimentem a cada frame.
Agora instanciamos um objeto InimigoManager no método create da nossa classe de jogo e chamamos o método atualizar e desenhar dentro do método render.
public class SpaceShooter extends ApplicationAdapter {
// ...
private InimigoManager inimigoManager; // adicionado
// ...
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
this.missil = new Missil();
this.naveJogador = new NaveJogador(this.missil);
this.inimigoManager = new InimigoManager(); // adicionado
}
//...
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
this.naveJogador.atualizar();
this.missil.atualizar();
this.inimigoManager.atualizar(); // adicionado
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.missil.desenhar(this.batch);
this.naveJogador.desenhar(this.batch);
this.inimigoManager.desenhar(this.batch); // adicionado
this.batch.end();
}
//...
}
Detectando Colisões
Agora que temos nossa nave se movendo, o míssil em ação e os inimigos na tela, você provavelmente percebeu que alguns elementos estão “passando por cima” uns dos outros. O próximo passo é fazer com que, ao colidir com um inimigo, o míssil destrua essa nave, e se a nave inimiga colidir com a nave do jogador, o jogo termine.
Para isso, é importante entender o conceito de colisão. Em termos simples, uma colisão ocorre quando um elemento se sobrepõe a outro. No nosso caso, todos os elementos (nave do jogador, inimigos e mísseis) são representados por Sprites, que possuem o formato de retângulos.

Podemos detectar uma colisão comparando os limites dos dois retângulos que representam os objetos na tela. A ideia é verificar se a área ocupada por um elemento se sobrepõe à área do outro, analisando suas posições e dimensões. Esse tipo de verificação é simples de implementar e atende bem a jogos simples como o nosso, onde tratamos os elementos do jogo como retângulos.
if(
missil.x + missil.width > inimigo.x &&
missil.x < inimigo.x + inimigo.width &&
missil.y + missil.height > inimigo.y &&
missil.y < inimigo.y + inimigo.height
){
// colidiu!!!
}
Existe, no entanto, uma maneira bem mais simples de lidar com colisões. Como os dois elementos do jogo envolvidos, o míssil e o inimigo, são Sprites, podemos aproveitar um recurso já fornecido pela própria classe Sprite.
O método getBoundingRectangle() retorna um objeto Rectangle contendo a posição (x, y) e as dimensões (width, height) do sprite. Mais importante ainda: a classe Rectangle possui o método overlaps(Rectangle), que recebe outro retângulo como parâmetro e retorna true quando os dois estão sobrepostos, ou seja, quando ocorre uma colisão.
Utilizar esse método torna o código mais limpo, legível e menos propenso a erros. Sendo assim, vamos adotar essa abordagem. Como primeiro passo, criaremos um método na classe Inimigo que retorna o Rectangle associado ao sprite.
public class Inimigo {
//...
public Rectangle getCaixaColisao(){
return this.sprite.getBoundingRectangle();
}
//..
}
Agora vamos adicionar à classe Missil um método responsável por verificar colisões. Esse método, chamado colidiu, receberá um objeto do tipo Inimigo como parâmetro e fará o teste de sobreposição entre as áreas de colisão. Caso o míssil esteja colidindo com o inimigo, o método retorna true; caso contrário, retorna false.
public class Missil {
//...
public boolean colidiu(Inimigo inimigo) {
if(!this.foiLancado){
return false;
}
boolean colidiu = this.sMissil.getBoundingRectangle().overlaps( inimigo.getCaixaColisao() );
if(colidiu){
this.foiLancado = false;
}
return colidiu;
}
//...
}
No método colidiu, a primeira verificação é se o míssil ainda não foi lançado. Caso isso aconteça, retornamos imediatamente false, afinal, um míssil que não está ativo não pode colidir com nada.
Em seguida, utilizamos o método getBoundingRectangle() do sprite do míssil e chamamos o método overlaps, passando como argumento o retângulo de colisão retornado pelo método getCaixaColisao() do inimigo. O resultado dessa verificação é armazenado na variável booleana colidiu.
Quando uma colisão é detectada, alteramos o estado do atributo foiLancado para false, impedindo que o míssil continue sendo desenhado e permitindo que ele seja lançado novamente. Por fim, retornamos o valor da variável colidiu.
Com essa lógica pronta, o próximo passo é criar, na classe InimigoManager, um método chamado detectarColisao, responsável por verificar a colisão do míssil com cada um dos inimigos ativos na tela.
public class InimigoManager{
//...
public boolean detectarColisao(Missil missil){
for(int i = 0 ; i < inimigos.size; i++){
if( missil.colidiu(inimigos.get(i) ) ){
this.inimigos.removeIndex(i);
return true;
}
}
return false;
}
//...
}
Aqui não há nenhum mistério. Recebemos o objeto Missil e percorremos a lista de inimigos ativos, ou seja, aqueles que ainda estão presentes no array. Para cada inimigo, chamamos o método colidiu do míssil. Caso a colisão seja detectada, removemos imediatamente o inimigo atual do array, fazendo com que ele deixe de ser atualizado e desenhado na tela, e retornamos true. Se o laço terminar sem que nenhuma colisão seja encontrada, retornamos false.
Com isso pronto, o próximo passo é apenas invocar o método detectarColisao do InimigoManager dentro do método render da classe principal do jogo.
public class SpaceShooter extends ApplicationAdapter {
//...
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
this.missil = new Missil();
this.naveJogador = new NaveJogador(this.missil);
this.inimigoManager = new InimigoManager();
}
//...
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
this.naveJogador.atualizar();
this.missil.atualizar();
this.inimigoManager.atualizar();
this.inimigoManager.detectarColisao(missil); // adicionado
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.missil.desenhar(this.batch);
this.naveJogador.desenhar(this.batch);
this.inimigoManager.desenhar(this.batch);
this.batch.end();
}
//...
}
Agora vamos implementar a detecção de colisão envolvendo a nave do jogador. A lógica será bem parecida com a utilizada para o míssil, mudando apenas os elementos envolvidos. O primeiro passo é adicionar o seguinte código diretamente na classe NaveJogador, onde faremos a verificação da colisão entre a nave e os inimigos.
public class NaveJogador {
//...
public boolean colidiu(Inimigo inimigo){
boolean colidiu = this.sNave.getBoundingRectangle().overlaps(inimigo.getCaixaColisao());
return colidiu;
}
//...
}
Também adicionamos um método correspondente na classe InimigoManager, que ficará responsável por verificar se algum dos inimigos ativos colidiu com a nave do jogador.
public class InimigoManager {
//...
public boolean detectarColisao(NaveJogador naveJogador){
for(int i = 0 ; i < inimigos.size; i++){
if( naveJogador.colidiu(inimigos.get(i) ) ){
return true;
}
}
return false;
}
//...
}
Aqui a lógica segue a mesma ideia das colisões anteriores. Percorremos todos os inimigos armazenados no array e, para cada um deles, executamos o teste de colisão chamando o método colidiu da classe NaveJogador, que é recebido como parâmetro. Caso alguma colisão seja detectada, retornamos true imediatamente. Se o loop terminar sem identificar nenhuma colisão, retornamos false.
Com isso pronto, o próximo passo é ajustar a classe principal do jogo para reagir a esse evento. Precisaremos detectar essa colisão durante o render e encerrar o jogo quando ela acontecer. Para organizar melhor essa lógica, vamos introduzir um estado de jogo, que poderá assumir valores como INICIO, JOGANDO e GAMEOVER. Quando o estado mudar para GAMEOVER, interrompemos a atualização dos elementos do jogo, como a nave do jogador, o míssil e os inimigos, congelando a ação na tela.
public class SpaceShooter extends ApplicationAdapter {
// adicionado
public enum EstadoJogo{
INICIO, JOGANDO, GAMEOVER
}
private SpriteBatch batch;
private Texture fundo;
private NaveJogador naveJogador;
private Missil missil;
private InimigoManager inimigoManager;
private EstadoJogo estadoJogo; // adicionado
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
this.missil = new Missil();
this.naveJogador = new NaveJogador(this.missil);
this.inimigoManager = new InimigoManager();
this.estadoJogo = EstadoJogo.INICIO; // adicionado
}
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
// adicionado
if(this.inimigoManager.detectarColisao(naveJogador)){
this.estadoJogo = EstadoJogo.GAMEOVER;
}
// adicionado
if( this.estadoJogo != EstadoJogo.GAMEOVER){
this.naveJogador.atualizar();
this.missil.atualizar();
this.inimigoManager.atualizar();
this.inimigoManager.detectarColisao(missil);
}
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.missil.desenhar(this.batch);
this.naveJogador.desenhar(this.batch);
this.inimigoManager.desenhar(this.batch);
this.batch.end();
}
@Override
public void dispose() {
this.fundo.dispose();
this.naveJogador.dispose();
this.missil.dispose();
this.inimigoManager.dispose();
}
}
Aqui, criamos um enum que define os estados do jogo. No método create, inicializamos o atributo estadoJogo com o valor INICIO. Esse estado será utilizado posteriormente para exibir uma mensagem, sinalizando que o jogador pode começar o jogo.
No método render, verificamos se houve colisão entre a nave do jogador e alguma nave inimiga, utilizando o método detectarColisao que recebe um objeto NaveJogador. Se a colisão for detectada, alteramos o estado do jogo para GAMEOVER. Em seguida, verificamos se o estado do jogo é diferente de GAMEOVER. Caso seja, o jogo prossegue com a atualização das lógicas e movimentos dos elementos, caso contrário, pulamos essa parte e mantemos o jogo em pausa, sem calcular mais nenhum movimento, apenas desenhando os elementos na tela em seu último estado.
Agora, vamos implementar a detecção de colisão das naves inimigas com a “parede da esquerda”. Se isso ocorrer, o jogo também será finalizado. A lógica para isso é simples: vamos criar um método na classe InimigoManager para testar se a posição x de alguma nave inimiga é menor que 0.
public class InimigoManager {
// ...
public boolean detectarColisaoParede(){
for(int i = 0 ; i < inimigos.size; i++){
if(inimigos.get(i).getCaixaColisao().getX() < 0){
return true;
}
}
return false;
}
//...
}
Em seguida, chamamos esse método juntamente com a verificação de colisão com a nave do jogador no método render da classe principal do jogo, garantindo que o estado seja alterado para GAMEOVER caso uma nave inimiga alcance a parede esquerda.
public class SpaceShooter extends ApplicationAdapter {
//...
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
if(this.inimigoManager.detectarColisao(naveJogador) || this.inimigoManager.detectarColisaoParede()){
this.estadoJogo = EstadoJogo.GAMEOVER;
}
//...
}
//...
}
Exibindo textos na tela
Agora que a lógica principal do jogo está pronta, vamos exibir uma mensagem na tela logo no início, solicitando que o jogador pressione ENTER para começar a partida.
Para desenhar textos na tela, utilizamos a classe BitmapFont. Nesse caso, ela será criada a partir de uma fonte FreeType (faça o download da fonte). O processo envolve a criação de um FreeTypeFontGenerator, que recebe o arquivo da fonte presente na pasta assets, e de um FreeTypeFontParameter, responsável por definir características como tamanho, cor, borda e outros estilos da fonte.
A seguir, veremos um exemplo de como criar um objeto BitmapFont a partir desses recursos.
FreeTypeFontGenerator fontGenerator = new FreeTypeFontGenerator(Gdx.files.internal("impact.ttf"));
FreeTypeFontParameter params = new FreeTypeFontParameter();
params.size = 48;
params.color = Color.WHITE;
params.borderWidth = 5;
params.borderColor = Color.BLACK;
bitmapFont = fontGenerator.generateFont(params);
Observação: É importante destacar que o projeto html não suporta fontes tff e se tentarmos exportar o projeto para html mais adiante utilizando a classe FreeTypeFontGenerator ele irá gerar um erro. Se desejar utilizar um projeto html utilize apenas new BitmapFont() para criar o objeto BitmapFont, ele irá utilizar uma fonte mais simples mas irá funcionar para web.
Para exibir texto na tela com um objeto BitmapFont, utilizamos o método draw(), que recebe um objeto SpriteBatch, o texto a ser exibido e as coordenadas x e y para definir a posição na tela.
bitmapFont.draw(batch, "Texto", 100, 200);
Caso desejemos centralizar o texto na tela, podemos utilizar a classe GlyphLayout, que permite atribuir o BitmapFont e o texto a ser exibido a ele através do método setText, e então ele fornece as dimensões do texto a ser exibido, o que facilita o cálculo das posições x e y para centralizá-lo corretamente.
A seguir, apresentamos a classe do jogo com as mensagens de Início e Game Over sendo exibidas.
public class SpaceShooter extends ApplicationAdapter {
public enum EstadoJogo{
INICIO, JOGANDO, GAMEOVER
}
private SpriteBatch batch;
private Texture fundo;
private NaveJogador naveJogador;
private Missil missil;
private InimigoManager inimigoManager;
private EstadoJogo estadoJogo;
private BitmapFont bitmapFont; // adicionado
private FreeTypeFontGenerator fontGenerator; // adicionado
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
this.missil = new Missil();
this.naveJogador = new NaveJogador(this.missil);
this.inimigoManager = new InimigoManager();
this.estadoJogo = EstadoJogo.INICIO;
// adicionado
this.fontGenerator = new FreeTypeFontGenerator(Gdx.files.internal("impact.ttf"));
FreeTypeFontParameter params = new FreeTypeFontParameter();
params.size = 48;
params.color = Color.WHITE;
params.borderWidth = 5;
params.borderColor = Color.BLACK;
this.bitmapFont = fontGenerator.generateFont(params);
}
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
// alterado
if(this.estadoJogo == EstadoJogo.JOGANDO){
if(this.inimigoManager.detectarColisao(naveJogador) || this.inimigoManager.detectarColisaoParede()){
this.estadoJogo = EstadoJogo.GAMEOVER;
}
this.naveJogador.atualizar();
this.missil.atualizar();
this.inimigoManager.atualizar();
this.inimigoManager.detectarColisao(missil);
}
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.missil.desenhar(this.batch);
this.naveJogador.desenhar(this.batch);
this.inimigoManager.desenhar(this.batch);
// adicionado
if(this.estadoJogo == EstadoJogo.INICIO){
this.showMessageInicio(batch);
}
// adicionado
if(this.estadoJogo == EstadoJogo.GAMEOVER){
this.showMessageGameOver(batch);
}
this.batch.end();
}
@Override
public void dispose() {
this.fundo.dispose();
this.naveJogador.dispose();
this.missil.dispose();
this.inimigoManager.dispose();
this.fontGenerator.dispose(); // adicionado
this.bitmapFont.dispose(); // adicionado
}
// adicionado
public void showMessageInicio(SpriteBatch batch){
GlyphLayout layout = new GlyphLayout();
layout.setText(bitmapFont, "Pressione Enter para começar");
float x = (Gdx.graphics.getWidth() - layout.width) / 2f;
float y = (Gdx.graphics.getHeight() + layout.height) / 2f;
this.bitmapFont.draw(batch, layout, x, y);
if(Gdx.input.isKeyPressed(Input.Keys.ENTER)){
this.estadoJogo = EstadoJogo.JOGANDO;
}
}
// adicionado
public void showMessageGameOver(SpriteBatch batch){
GlyphLayout layout = new GlyphLayout(bitmapFont, "Game Over! Pressione Enter para reiniciar");
float x = (Gdx.graphics.getWidth() - layout.width) / 2f;
float y = (Gdx.graphics.getHeight() + layout.height) / 2f;
this.bitmapFont.draw(batch, layout, x, y);
if(Gdx.input.isKeyPressed(Input.Keys.ENTER)){
this.estadoJogo = EstadoJogo.JOGANDO;
this.naveJogador.reset();
this.inimigoManager.reset();
}
}
}
Criamos dois novos métodos: showMessageInicio e showMessageGameOver.
O método showMessageInicio é responsável por exibir a mensagem “Pressione Enter para começar” centralizada na tela e verificar se a tecla Enter foi pressionada. Quando isso ocorre, o estado do jogo é alterado para JOGANDO.
Já o método showMessageGameOver exibe a mensagem “Game Over! Pressione Enter para reiniciar”, também centralizada. Ao detectar o pressionamento da tecla Enter, o estado do jogo volta para JOGANDO e são chamados os métodos reset da NaveJogador e do InimigoManager, que reinicializam as posições e estados desses elementos, veremos esses métodos a seguir.
No método render, verificamos o estado atual do jogo: se estiver em INICIO, chamamos showMessageInicio; se estiver em GAMEOVER, chamamos showMessageGameOver.
A seguir, apresentamos o método reset da classe NaveJogador. Nele, o posicionamento inicial da nave é removido do construtor e centralizado no método reset, que também é chamado no construtor para garantir a inicialização correta.
public class NaveJogador {
//...
public NaveJogador(Missil missil){
this.tNave = new Texture("nave.png");
this.sNave = new Sprite(tNave);
this.missil = missil;
this.reset(); // alterado
}
//...
public void reset() {
this.x = 20;
this.y = Gdx.graphics.getHeight() / 2 - tNave.getHeight() / 2;
}
//...
}
Agora vamos ao método reset da classe InimigoManager.
public class InimigoManager {
//...
public void reset() {
this.inimigos.clear();
this.ultimoInimigo = 0;
this.tempoIntervalorGerar = 3000;
}
//...
}
No método reset da classe InimigoManager esvaziamos o array de inimigos ativos, redefinimos o tempo do último inimigo criado e restauramos o intervalo de geração de inimigos para o valor inicial.
Agora que nosso jogo não inicia imediatamente devemos remover a criação do primeiro inimigo do construtor da classe InimigoManager, pois o jogo começará pausado até a tecla Enter ser pressionada, o que ultrapassará o tempo de intervalor de criação de um novo inimigo fazendo o jogo começar com dois inimigos na tela;
public InimigoManager(){
texture = new Texture("naves-inimigas.png");
this.inimigos = new Array<>();
// this.inimigos.add( this.gerarInimigo()); <-- remover
}
Quase lá! O Score
Agora falta apenas contabilizar a pontuação quando um inimigo for destruído. Para isso, vamos criar um atributo score e incrementá-lo sempre que o míssil colidir com uma nave inimiga.
Também criaremos um novo BitmapFont, com tamanho menor, para exibir a pontuação no canto superior esquerdo da tela.
public class SpaceShooter extends ApplicationAdapter {
public enum EstadoJogo{
INICIO, JOGANDO, GAMEOVER
}
private SpriteBatch batch;
private Texture fundo;
private NaveJogador naveJogador;
private Missil missil;
private InimigoManager inimigoManager;
private EstadoJogo estadoJogo;
private BitmapFont bitmapFont;
private FreeTypeFontGenerator fontGenerator;
private int score = 0; // adicionado
private BitmapFont bitmapFontScore; // adicionado
@Override
public void create() {
this.batch = new SpriteBatch();
this.fundo = new Texture("espaco.png");
this.missil = new Missil();
this.naveJogador = new NaveJogador(this.missil);
this.inimigoManager = new InimigoManager();
this.estadoJogo = EstadoJogo.INICIO;
this.fontGenerator = new FreeTypeFontGenerator(Gdx.files.internal("impact.ttf"));
FreeTypeFontParameter params = new FreeTypeFontParameter();
params.size = 48;
params.color = Color.WHITE;
params.borderWidth = 5;
params.borderColor = Color.BLACK;
this.bitmapFont = fontGenerator.generateFont(params);
// adicionado
params = new FreeTypeFontParameter();
params.size = 32;
params.color = Color.WHITE;
params.borderWidth = 3;
params.borderColor = Color.BLACK;
this.bitmapFontScore = this.fontGenerator.generateFont(params);
}
@Override
public void render() {
ScreenUtils.clear(Color.BLACK);
if(this.estadoJogo == EstadoJogo.JOGANDO){
if(this.inimigoManager.detectarColisao(naveJogador) || this.inimigoManager.detectarColisaoParede()){
this.estadoJogo = EstadoJogo.GAMEOVER;
}
this.naveJogador.atualizar();
this.missil.atualizar();
this.inimigoManager.atualizar();
// alterado
if(this.inimigoManager.detectarColisao(missil)){
score++;
}
}
this.batch.begin();
this.batch.draw(this.fundo, 0, 0);
this.missil.desenhar(this.batch);
this.naveJogador.desenhar(this.batch);
this.inimigoManager.desenhar(this.batch);
if(this.estadoJogo == EstadoJogo.INICIO){
this.showMessageInicio(this.batch);
}
if(this.estadoJogo == EstadoJogo.GAMEOVER){
this.showMessageGameOver(this.batch);
}
// adicionado
this.bitmapFontScore.draw(this.batch, "Score: " + this.score, 10, Gdx.graphics.getHeight() - 30);
this.batch.end();
}
@Override
public void dispose() {
this.fundo.dispose();
this.naveJogador.dispose();
this.missil.dispose();
this.fontGenerator.dispose();
this.bitmapFont.dispose();
this.bitmapFontScore.dispose();
}
public void showMessageInicio(SpriteBatch batch){
GlyphLayout layout = new GlyphLayout();
layout.setText(bitmapFont, "Pressione Enter para começar");
float x = (Gdx.graphics.getWidth() - layout.width) / 2f;
float y = (Gdx.graphics.getHeight() + layout.height) / 2f;
this.bitmapFont.draw(batch, layout, x, y);
if(Gdx.input.isKeyPressed(Input.Keys.ENTER)){
this.estadoJogo = EstadoJogo.JOGANDO;
}
}
public void showMessageGameOver(SpriteBatch batch){
GlyphLayout layout = new GlyphLayout(bitmapFont, "Game Over! Pressione Enter para reiniciar");
float x = (Gdx.graphics.getWidth() - layout.width) / 2f;
float y = (Gdx.graphics.getHeight() + layout.height) / 2f;
this.bitmapFont.draw(batch, layout, x, y);
// adicionado
GlyphLayout layout2 = new GlyphLayout(this.bitmapFont, "Score: " + this.score);
this.bitmapFont.draw(batch, layout2, (Gdx.graphics.getWidth() - layout2.width) / 2f, y - layout2.height - 30);
if(Gdx.input.isKeyPressed(Input.Keys.ENTER)){
this.estadoJogo = EstadoJogo.JOGANDO;
this.naveJogador.reset();
this.inimigoManager.reset();
this.score = 0; // adicionado
}
}
}
Finalizando com um pouco de música e explosões
A libGDX oferece duas abordagens principais para trabalhar com áudio em jogos. A primeira é o uso de uma trilha sonora, que geralmente possui maior duração e, por isso, deve ser reproduzida por streaming diretamente do disco, evitando o carregamento completo na memória. Para esse tipo de áudio utilizamos a classe Music, fornecida pelo módulo de áudio da libGDX, que funciona como um player e disponibiliza métodos como play(), stop(), pause() e setLooping().
Faça o download de uma música para servir como trilha sonora do jogo e adicione o arquivo à pasta de assets. A seguir, veremos o código atualizado da classe principal do jogo.
public class SpaceShooter extends ApplicationAdapter {
//...
private Music musica;
//...
@Override
public void create() {
//....
this.musica = Gdx.audio.newMusic(Gdx.files.internal("galactic-blaster-suno.mp3"));
this.musica.setLooping(true);
this.musica.setVolume(0.5f);
this.musica.play();
//...
}
//...
@Override
public void dispose() {
//...
this.musica.dispose();
}
//...
}
Para efeitos sonoros curtos, como explosões, tiros e impactos, a libGDX disponibiliza a classe Sound, que é mais adequada para esse tipo de áudio rápido e repetitivo. Diferente da trilha sonora, esses sons são totalmente carregados em memória, permitindo uma reprodução imediata e com baixa latência.
Vamos então adicionar um efeito sonoro de explosão para ser executado sempre que um inimigo for destruído. Para isso, faça o download de um som de explosão e adicione o arquivo à pasta de assets. A seguir, veremos o código da classe InimigoManager com essa funcionalidade.
public class InimigoManager {
//...
private Sound explosao;
//...
public InimigoManager(){
texture = new Texture("naves-inimigas.png");
this.inimigos = new Array<>();
this.reset();
this.explosao = Gdx.audio.newSound(Gdx.files.internal("explosion.mp3"));
}
//...
public void dispose(){
this.texture.dispose();
this.explosao.dispose();
}
//...
public boolean detectarColisao(Missil missil){
for(int i = 0 ; i < inimigos.size; i++){
if( missil.colidiu(inimigos.get(i) ) ){
this.inimigos.removeIndex(i);
this.explosao.play(); // adicionado
return true;
}
}
return false;
}
//...
}
Agora vamos adicionar um efeito sonoro ao disparo do míssil. Sempre que o jogador pressionar a tecla de espaço e o míssil for lançado, o som será reproduzido, reforçando a sensação de ação no jogo.
Para isso, faça o download do arquivo de áudio do disparo e coloque-o na pasta de assets. A seguir, veja as alterações realizadas na classe NaveJogador.
public class NaveJogador {
//..
private Sound tiro;
public NaveJogador(Missil missil){
this.tNave = new Texture("nave.png");
this.sNave = new Sprite(tNave);
this.missil = missil;
this.tiro = Gdx.audio.newSound(Gdx.files.internal("shoot.mp3")); // adicionado
}
//...
public void atualizar(){
// ...
if(Gdx.input.isKeyPressed(Input.Keys.SPACE) && !this.missil.foiLancado()){
this.missil.lancar(this.x, this.y);
this.tiro.play(); // adicionado
}
}
// ...
public void dispose(){
tNave.dispose();
tiro.dispose(); // adicionado
}
//....
}
Exportando o jogo
Com tudo pronto, já podemos gerar a versão desktop do jogo executando o seguinte comando:
gradlew.bat lwjgl:dist
Ao executar esse comando, o Gradle irá gerar o arquivo JAR do jogo dentro do diretório lwjgl3/build/libs. Com esse arquivo em mãos, basta executá-lo utilizando o comando java -jar, iniciando assim o jogo na versão desktop.
cd lwjgl3/build/libs
java -jar SpaceShooter-1.0.0.jar
Para exportar o jogo para a versão HTML, basta usar o comando:
gradlew.bat html:dist
* Lembrando que não podemos utilizar fontes tff, apenas fontes mais simples instanciadas com new BitmapFont, altere o projeto para testar.
Isso irá gerar os arquivos necessários para rodar o jogo diretamente no navegador, criando uma pasta html no diretório de saída(html\build\dist). Em seguida, você pode abrir o arquivo index.html na pasta gerada para testar o jogo no navegador.
Pronto, chegamos ao final \o/. Apesar de longo, este tutorial percorreu passo a passo a construção de um jogo simples, que ainda pode (e deve) receber diversas otimizações e melhorias. Ainda assim, ele cumpriu bem o objetivo de apresentar os conceitos fundamentais e o funcionamento da libGDX na prática. Trata-se de uma biblioteca bastante poderosa, com muitos outros recursos interessantes que podem ser explorados em projetos mais avançados.
Você pode conferir o projeto completo no GitHub.
