de jogos J2ME/MIDP para celular

Este artigo apresenta uma introdução à programação de jogos para celular. Partindo de conceitos básicos de programação Java/MIDP, você será capaz de, ao final do artigo, construir seu próprio jogo Arcade.

Iremos apresentar um estudo de caso de um jogo feito exclusivamente para o artigo, entitulado “Mobile Invaders”. Como o nome sugere, é um jogo baseado no Space Invaders do Atari 2600, onde o jogador controla uma nave que tem que destruir as naves adversárias antes que elas se aproximem. É um jogo bastante interessante pois, além de divertido, ilustra bem os conceitos de criação de jogos.

Tipos de jogos

Para desenvolver um jogo de sucesso é preciso ter conhecimento não apenas sobre programação. É necessário ter uma visão de mercado, não apenas do mercado de jogos MIDP, mas do mercado de jogos para dispositivos móveis em geral, incluindo outras tecnologias. É importante saber o que faz com que um jogo tenha chances de entrar no mercado. Em suma, conhecer vários jogos ajuda você a ter idéias para desenvolver o seu próprio jogo - é interessante saber em quais pontos outros jogos acertaram e erraram.

Um ponto que facilita na criação e concepção de um jogo é sua classificação. Isso vai auxiliar no game design e na programação em si. Vamos conhecer alguns tipos de jogos:

  • Arcade: jogos clássicos dos anos 80, como Asteroids, Space Invaders e Pac-Man;
  • Carta: simulam um jogo específico de cartas como Poker ou Truco;
  • Puzzle: privilegiam o raciocínio rápido, como Tetris e Shapes and Collumns;
  • Jogos de tabuleiro: simulam os antigos jogos de tabuleiro como Xadrez e Damas;
  • Aventura em texto: jogos totalmente em texto, em que um cenário é descrito e o jogador tem uma lista de decisões possíveis;
  • Aventura gráfica: basicamente é a evolução da aventura em texto. São mostradas figuras estáticas ou dinâmicas de cenários e o jogador pode interagir com elas, como Space Quest;
  • Side scrollers: conhecidos como “de plataforma”, são jogos 2D em que o personagem tem que pular buracos e atirar em inimigos, como Sonic e Super Mario;
  • RPG: em geral possuem uma história complexa, onde o personagem evolui durante o jogo e pode interferir em toda a história. Exemplos são as séries Ultima e Final Fantasy.

Essa classificação serve para o desenvolvedor definir as regras do jogo, o público alvo e a plataforma alvo. Essas escolhas devem ser consistentes. É difícil, por exemplo, obter sucesso se você quiser fazer um jogo 3D para um celular simples, pois ele provavelmetne ficará muito lento e a jogabilidade muito prejudicada. Outro fato que ajuda é ter um grupo de jogadores com o perfil alvo para testar e fazer sugestões sobre o jogo, como nível de dificuldade, arte ou interface. Outro ponto que gostaríamos de citar é que alguns jogos misturam mais de um elemento, ficando essa decisão por conta do game designer.

Conceitos de jogos

Para que o leitor possa acompanhar melhor o artigo, introduziremos alguns conceitos aqui.

  • Curva de aprendizagem: é o tempo que um jogador necessita para assimilar os comandos e as regras do jogo. Existem principalmente duas abordagens utilizadas para permitir que o jogador aprenda as regras e os comandos: (1) através da criação de uma fase do jogo, conhecida como tutorial, onde os comandos e as regras são explicitamente demonstrados e (2) através da criação de fases iniciais com níveis de dificuldade baixos. Essas abordagens facilitam a despertar o interesse do jogador.
  • Tempo de vida do jogo: quantas horas em média um usuário vai continuar jogando este jogo antes de perder o interesse por ele.
  • Fator de replay: quantas vezes o jogador vai jogar novamente, mesmo já tendo terminado o jogo.
  • Fator de replay: quantas vezes o jogador vai jogar novamente, mesmo já tendo terminado o jogo.
  • FPS (frames per second): significa quadros por segundo. Define quantos quadros são mostrados ao usuário em um segundo de jogo. Para jogos em celulares, o mínimo aceitável são 10 quadros por segundo. Quanto maior a quantidade de quadros por segundo, maior é o processamento requerido.
  • Plataforma-alvo: celular ou conjunto de celulares que serão capazes de rodar o jogo.
  • Público-alvo: pessoas às quais o jogo se destina.
  • Turno de jogo: conjunto de atividades que o jogo faz a cada frame de jogo. Entre atividades típicas, estão repintar a tela, verificar os pontos do jogador ou colisões entre os sprites.

Processo de criação

Para a construção de jogos temos uma série de passos que devem ser seguidos para facilitar seu desenvolvimento e sua viabilidade. Nesse artigo, dividimos esses passos em oito etapas para facilitar o entendimento: pesquisa, público-alvo, plataforma-alvo, regras, layout, arte, codificação e teste. Esse processo é apresentado como uma cascata de etapas Figura 1, mas durante o desenvolvimento ele é iterativo e incremental.

construcao-jogo
Figura 1. Diagrama do processo de criação.

Público-alvo e plataforma-alvo

Essas duas etapas estão interligadas. A definição desses dois elementos é necessária para saber se o jogo é viável ou não. Se forem definidos um público-alvo muito restrito e uma plataforma-alvo com poucos celulares, o jogo provavelmente vai vender pouco. Além disso, deve ser feita a análise de mercado para o lançamento do jogo. Por exemplo, se o tempo de desenvolvimento for de um ano, deve-se prever qual a melhor plataforma para o próximo ano. A queda de preços é acelerada e celulares populares podem ser substituídos por mais rápidos e com mais recursos.

Para nosso estudo de caso, definimos:

  • O público alvo é o de jogadores eventuais, ou seja, aqueles que vão jogar na fila do banco, ou enquanto esperam uma carona. A curva de aprendizagem deste jogo é muito pequena, pois o jogo tem poucos comandos e um objetivo muito simples e fácil de entender. O fator de replay deste tipo de jogo é grande, pois você sempre tem a motivação de conseguir uma pontuação maior que seu recorde anterior (ou maior que a de seu amigo).
  • A plataforma alvo escolhida foi a série 60. Esta plataforma compreende celulares Nokia (3650, N-Gage, 6600), Siemens (SX-1), Panasonic (X700), entre outros. Essa plataforma é uma família de smartphones, que utilizam o sistema operacional Symbian. Entretanto, um detalhe técnico (explicado mais adiante) irá determinar que o jogo conforme implementado só poderá rodar em aparelhos série 60 da Nokia.

Regras do jogo

Com um tipo de jogo definido, começa a definição das regras. Essa etapa será a base para as etapas futuras do desenvolvimento. Uma forma de definir as regras é avaliar e analisar os jogos atuais semelhantes ao que será construído e tentar criar uma regra nova para tornar o jogo diferente. Além disso, procurar detectar a razão desses jogos terem ficado conhecidos (ou não) também pode dar boas idéias. Claro que isso não exclui a possibilidade de você ter uma idéia diferente, ou juntar idéias de jogos de tipos diferentes.

Para o Mobile Invaders definimos o seguinte: uma nave controlada pelo jogador, que tem a possibilidade de atirar até três tiros simultâneos, com o objetivo de destruir os inimigos na tela. Existem 18 inimigos na tela, em três fileiras de seis inimigos cada, com a possibilidade de atirar até dois tiros simultâneos (no total). O jogador perde se as naves inimigas chegarem muito próximo do final da tela ou se ele for atingido por um tiro e vence se ele destruir todas as naves inimigas.

Layout do jogo

Definida a plataforma alvo, sabe-se o tamanho da tela e a capacidade de cores do aparelho. Temos também as regras e quais são os sprites necessários e suas ações. Agora vamos criar um layout de tela para saber o tamanho que os sprites irão ocupar na tela. Como ainda não foi definida a arte, você pode criar este layout com figuras geométricas simples. Isso irá inclusive orientar o artista, pois os desenhos dos sprites deverão seguir estes tamanhos. Este layout também auxilia para descobrir quantos objetos cabem na tela do jogo. Uma técnica que pode ser utilizada por jogos mais complexos é a do storyboard. Nela, faz-se uma seqüência de diagramas que mostram a movimentação do personagem, o posicionamento dele com os inimigos, como fica a cena de tiro, etc. Essa técnica facilita bastante o trabalho em jogos mais complexos.

Para o Mobile Invaders, definimos o seguinte: três tamanhos de objetos: a nave do jogador, as naves inimigas e o tiro (que é o mesmo para o jogador e para os inimigos). Fizemos então uma tela de testes de 176 X 208 pixels (uma das telas possíveis para um série 60). A partir daí, fizemos algumas tentativas para acertar o tamanho dos sprites. Inicialmente, pensamos que o inimigo pudesse ter 20 X 20 pixels, porém poucos inimigos iriam caber na tela. Foi acertado então um inimigo de 15 X 15, uma bala de 7 X 10, e uma nave de 20 X 30. A Figura 2 mostra o resultado desta tela, que foi passada para a artista desenhar os sprites.

layout
Figura 2. Layout de tela do jogo.

Arte do jogo

Outro ponto importante é definir a arte do jogo. O formato para as figuras deve ser .png (portable network graphics) para ficarem compatíveis com o MIDP 1.0. A maioria dos celulares tem uma capacidade de cores reduzida (4096 cores ou, no máximo, 65536). Usar uma paleta de cores reduzida ajuda a manter o tamanho das figuras reduzidas e diminuir alteração de cor na tela do celular.

Uma vantagem do uso do .png é que ele suporta transparência de pixels. Assim, apesar da nave estar armazenada em uma imagem retangular, ela não é retangular, possuindo uma forma mais complexa. Adicionando transparência aos pixels que não pertencem à nave, nada precisa ser feito quando a imagem for colocada na tela: a nave irá aparecer em cima do que antes existia naquele ponto, e os pixels transparentes serão ignorados.

Além do formato e da cor utilizada na arte do jogo, deve-se ter atenção com a resolução utilizada para criá-los. Desenhos que foram criados em alta resolução podem ficar impossíveis de se utilizar caso sejam reduzidos no momento de aplicá-los ao jogo. Isso pode gerar tantos ajustes que pode ficar mais fácil recriar a arte.

Para nosso jogo definimos o seguinte: com base na Figura 2 feita pelos desenvolvedores, a artista desenvolveu a arte mostrada na Figura 3>.

arte-do-jogo
Figura 3. Arte do jogo

Implementando o jogo

Daqui para frente nos concentraremos apenas nos detalhes de implementação do Mobile Invaders. O programa utilizado para criar o código foi o NetBeans 3.6 e o J2ME Wireless Toolkit 2.2 Beta (WTK). No WTK, foi instalado o emulador específico da plataforma-alvo, o Series 60 MIDP Concept Beta 0.3.1 (ver Nota 1). A forma de integração entre esses softwares se encontra explicada nos links e em um documento que acompanha o código do artigo, disponível no site da WebMobile.

As classes desenvolvidas podem ser divididas em dois grupos: classes de apoio e classes de jogo. O primeiro conunto é responsável por funções iniciais como menus, splash screens e inicializar a arte do jogo. O outro grupo está diretamente relacionado à parte jogável do Mobile Invaders, como a lógica do jogo e os sprites. A classe que conecta esses dois grupos é o MIDlet (MobileInvadersMidlet).

O ciclo de vida do jogo pode ser visto na Figura 4. Esse ciclo foi representado por uma máquina de estados onde cada estado foi denominado com a classe mais importante para a atividade. Além disso, existe um texto associado a cada estado para explicar que ações estão relacionadas ao estado.

Quando o jogador inicia o jogo ele na verdade inicia o MIDlet do jogo. Este Midlet cria os objetos necessários e passa o display para o MenuCanvas. Este, por sua vez, faz o processo de mostrar as splash screens e, em seguida, mostrar o menu. Quando o jogador clica em play o método iniciaJogo() do Engine é chamado, criando os objetos necessários e iniciando a thread do GameCanvas, que agora tem o display. Quando o jogador clica em back o jogo volta a mostar o MenuCanvas na tela e, se ele clica em exit neste menu, ele volta para o Midlet para destruir a aplicação.

diagrama-jogo
Figura 4. Diagrama de estados do jogo.

Nota 1. Instalando o emulador e executando o exemplo.

O Mobile Invaders foi criado utilizando o Wireless ToolKit (WTK)versão 2.2 beta 2 e o emulador Nokia Serie 60 Concept vesão 0.3.1.

Para instalar o WTK, faça o download do site:

Depois basta executar e seguir as instruções na tela.

Feito isto, será necessário instalar o emulador. Seu download pode ser efetuado a partir do site:

Para tornar disponível o emulador dentro do WTK é preciso mudar o diretório do Nokia Concept para o diretório \wtklib\devices . Isto tem que ser feito durante a instalação, quando o instalador solicita o diretório onde você deseja instalar o programa.

Depois, para executar o exemplo, faça o download do arquivo do site da revista e descompacte para o diretório \apps

Em seguida, basta abrir o KToolbar do WTK, selecionar a opção Open Project, selecionar o Mobile Invaders e executá-lo utilizando o emulador Concept.

Nota 2. MIDP 1.0 ou MIDP 2.0?

MIDP (Mobile Information Device Profile – Perfil de informação do dispositivo móvel) é um conjunto de bibliotecas (denominado perfil) que atuam como uma extensão ao J2ME, acrescentando novos recursos a esta linguagem. A primeira versão (MIDP 1.0) fornece as API's básicas para interface com o usuário (GUI), persistência de dados, conexão http entre outras coisas. A nova versão (MIDP 2.0) oferece recursos como conexão segura, recursos multimídia, além de uma melhoria nas API's já existentes. Para desenvolvimento de jogos, a nova versão oferece a Game API, que amplifica a capacidade gráfica do MIDP de várias maneiras. A maioria dos aparelhos encontrados no Brasil hoje possuem a versão MIDP 1.0, que foi um dos fatores que influenciou nossa escolha por seu uso.

Nota 3. Dica

Para conseguir utilizar toda a tela do celular em MIDP 1.0 nos celulares Nokia é preciso utilizar uma classe proprietária denominada FullCanvas. A forma de usá-la é muito similar ao Canvas de MIDP 1.0. Assim como o Canvas do MIDP 1.0, o FullCanvas é uma classe abstrata que lida com interface gráfica de baixo nível. Para utilizá-lo em seu programa, deve-se importar o pacote proprietário e estender a classe, sobrescrevendo o método paint(Graphics graphics). Quase todas as outras empresas fornecem API`s proprietárias para resolver este problema. Uma limitação desse uso é que o jogo só vai rodar nos celulares que possuem essa API (celulares Nokia). Então, para portar o jogo para outro celular essa classe deve ser substituída, colocando em seu lugar a API proprietária respectiva. Isso significa, infelizmente, que deve ser gerado um arquivo jar para cada marca. Em MIDP 2.0 essa limitação de não usar a tela toda pela classe Canvas foi resolvida com o método setFullScreenMode, possibilitando seu uso em aparelhos de todas as marcas.

Classes de apoio

Iniciamos criando a classe necessária para qualquer aplicativo J2ME (MIDlet), e duas classes de apoio (GameFactory e MenuCanvas). O diagrama que reflete as classes construídas é mostrado na Figura 5.

Diagrama-das-classes-de-apoio
Figura 5. Diagrama das classes de apoio.

No caso do Mobile Invaders, o MIDlet tem uma função muito simples: criar os objetos e chamar o MenuCanvas. As funções usuais, como escutar os comandos do usuário, são responsabilidade dos objetos canvas criados, que são o GameCanvas e o MenuCanvas, e por isso este MIDlet não implementa CommandListener.

A classe GameFactory é responsável por colocar as imagens na memória para os outros objetos acessarem. Isso é feito em três etapas para tornar o carregamento do jogo mais rápido. A primeira etapa acontece na instanciação do objeto e as outras duas são feitas quando os métodos loadImages1() e loadImages2() são chamados. A classe responsável por controlar o tempo de splash screen e chamar os métodos da GameFactory é o MenuCanvas. Na Listagem 1 temos parte da classe MenuCanvas e seu método doSplashScreen().

Listagem 1. Código que gera as splash screens do Mobile Invaders. Este segmento encontra-se na classe MenuCanvas

 
//Método que carrega as splash screens.

//Carrega algumas imagens por vez, enquanto mostra a splash screen,

//para acelerar o processo.
    public void doSplashScreen() {

        long inicio,tempo;

        inicio = System.currentTimeMillis();

        mobileInvadersMidlet.gameFactory.loadImages1();

        splashScreen2 = mobileInvadersMidlet.gameFactory.splashScreen2;

        menu = mobileInvadersMidlet.gameFactory.menu;

        tempo = System.currentTimeMillis() - inicio;

        if (tempo < 2000) {

            try {

                Thread.sleep(2000 - tempo);

            } catch (InterruptedException ioe) {

            }   

        }
       
        inicio = System.currentTimeMillis();

        telaAtual = SPLASH_SCREEN_2;

        repaint();

        serviceRepaints();

        mobileInvadersMidlet.gameFactory.loadImages2();

        tempo = System.currentTimeMillis() - inicio;

        if (tempo < 2000) {

            try {

                Thread.sleep(2000 - tempo);

            } catch (InterruptedException ioe) {

            }   

        }

        telaAtual = MENU;

        repaint();

        serviceRepaints();

    }
    

Caso o jogo possua mais menus (opções, high score, créditos) é interessante que estes sejam criados na classe MenuCanvas (ou em outras classes de apoio) e não na classe GameCanvas (responsável pelos gráficos do jogo). Isto por que se todas as funções estiverem agregadas em apenas um canvas, os métodos paint() e keyPressed se tornariam muito grandes e complexos para o entendimento e manutenção do código.

Classes de jogo

Agora serão apresentadas as classes responsáveis por controlar e desenhar a tela de jogo. Serão mostrados exemplos sobre os principais pontos de implementação, mostrando trechos de código seguidos de uma breve explicação.

Classe Sprite

Elaboramos uma classe abstrata Sprite com os métodos de colisão e movimento já implementados, e dois métodos abstratos desenhaSprite() e atualizar() que devem ser sobrescritos por suas subclasses. O diagrama representando a classe Sprite e suas subclasses é mostrado na Figura 6. Os atributos da classe Sprite são explicados na Figura 7.

Diagrama-da-classe-Sprite-e suas-subclasses
Figura 6. Diagrama da classe Sprite e suas subclasses. Alguns métodos e atributos não foram mostrados para simplificar a visualização.
Atributos-da-classe-Sprite
Figura 7. Atributos da classe Sprite.

Movimentação

Por se tratar de um jogo 2D, existe um plano cartesiano XY, onde os eixos X e Y têm origem no canto superior esquerdo, como mostra a Figura 7. O eixo Y está na vertical e aumenta de cima para baixo, enquanto o eixo X está na horizontal e aumenta da esquerda para direita. O método de movimentação da classe Sprite apenas move a origem do sprite de uma coordenada para outra. Para movimentar um sprite no jogo, são usados dois atributos, velocidadeX e velocidadeY, que pertencem a subclasse que herda de Sprite, pois nem todos os sprites de jogo se movem nas duas direções. Para movimentar o sprite, portanto, faz-se uma chamada ao método:

sprite.moverPara(sprite.x + sprite.velocidadeX, sprite.y + sprite.velocidadeY);

Assim, a velocidadeY determina quantos pixels o sprite vai se mover na direção Y, sendo o sinal a direção desse movimento (positivo para baixo, negativo para cima), e da mesma forma acontece com o eixo x (com o sinal positivo denotando velocidade para a direita e negativo para a esquerda). O código da Listagem 2 mostra como é implementada a movimentação dos sprites na tela

Listagem 2. Código de movimentação da classe Sprite. Este segmento encontra-se na classe Sprite.


//Método que muda a posição do sprite.

    public void moverPara(int x,int y) {

        this.x = x;

        this.y = y;

    }

Colisão

Para simplificar o entendimento, o método de detecção de colisão definido é bastante simples. Utilizando os parâmetros de posição e tamanho (x, y, largura e altura) da classe Sprite, foi criado um método que checa se os dois sprites, considerados como retângulos, se intersectam em algum ponto da tela. Isso gera imperfeições principalmente no personagem, pois a nave não ocupa toda a área do retângulo. A Listagem 3 mostra como foi implementado.

Listagem 3 . Código de detecção de colisão do Mobile Invaders. Este segmento encontra-se na classe Sprite.

//Método de colisão de um sprite com outro sprite.

//Retorna true se a colisão for detectada e false caso contrário.

//A implementação é bem simples, apenas detectando a intersecção dos sprites

//na tela, considerados como quadrados.   

public boolean colide(Sprite outroSprite) {

        if ((((x < outroSprite.x) && (x + largura > outroSprite.x))||((x > outroSprite.x) && (x < outroSprite.x + outroSprite.largura))) &&

            (((y < outroSprite.y) && (y + altura > outroSprite.y))||((y > outroSprite.y) && (y < outroSprite.y + outroSprite.altura))) ) {

                return true;

        } else {

            return false;

        }

           

    }

A Game API, do MIDP 2.0, fornece uma classe Sprite com métodos de colisão mais elaborados, que fazem inclusive colisão considerando os pixels transparentes da imagem.

Os sprites de jogo

Na nossa implementação três classes herdam diretamente de Sprite: Bullet, Enemy e Personagem. Elas se referem aos três elementos existentes no jogo. Todas as naves inimigas existentes na tela são objetos da classe Enemy, e todas as balas existentes no jogo (tanto as disparadas pelo jogador quanto as disparadas pelos inimigos) são objetos da classe Enemy. A classe Enemy tem uma variável do tipo Sprite (dono) que se referencia a quem disparou a bala.

A classe Personagem tem dois novos atributos básicos: explodindo e counter, que são usados para gerenciar a explosão (feita de forma bem simples, popularmente utilizada no Atari 2600, apenas piscando o sprite). Ela possui também referências ao engine e ao midlet. A referência ao engine (analisado mais abaixo) é utilizada para ler as variáveis leftPressed e rightPressed, que indicam que o jogador pressionou essas teclas, e para fazer mudanças no ambiente do jogo, como colocar uma nova bala ou mesmo terminar o jogo. Essa rotina de atualização e de relacionamento com a engine é apresentada na Listagem 4.

Listagem 4. Código que define como atualizar o Personagem e a relação com a engine do jogo. Este segmento encontra-se na classe Personagem.


//Faz o update do estado do jogador.

    public void atualizar() {

        if (!explodindo && !engine.gameOver) {

            if (engine.rightPressed) {

                moveDireita();

            } else if (engine.leftPressed) {

                moveEsquerda();

            }

        } else if (explodindo) {

            if (counter++ > 5) {

                explodindo = false;

                ativo = false;

                engine.terminaJogo();

            }

        }

        if (engine.leftPressed) {

            frameAtual = 0;

        } else if (engine.rightPressed) {

            frameAtual = 2;

        } else {

            frameAtual = 1;

        }

       

    }

   

    //Método usado para colocar um objeto bullet na tela.

    //Chamado pelo GameCanvas ao apertar a tecla Fire / Num5

    public void atira() {

        if (numeroDeBullets < Engine.PLAYER_MAX_BULLETS) {

            engine.setBullet(this);

            numeroDeBullets++;

        }

    }

 

A classe e Enemy. Elas se referem aos três elementos existentes no jogo. Todas as naves inimigas existentes na tela são objet tem os mesmos atributos e referências que a classe Personagem, acrescida de mais cinco novos atributos: linha, counterY, random, velocidadeY e velocidadeX. A variável linha corresponde à linha que este objeto pertence nas fileiras de inimigos, e é usada para controlar a direção da variável velocidadeX de todos os inimigos desta fileira, de modo que quando o inimigo da ponta alcançar o fim da tela e voltar, todos os da fileira também mudem de direção. A variável counterY é utilizada para controlar a variável velocidadeY, de modo que os inimigos não desçam rápido demais. Todas essas rotinas devem ser chamadas em todo turno de jogo, e são executadas quando o método atualizar(), mostrado na Listagem 5, é chamado.

Listagem 5. Método atualizar que define as ações dos inimigos na tela. Este segmento encontra-se na classe Enemy.


//Faz o update do estado do inimigo.

    public void atualizar() {

       

        if (!explodindo && !engine.gameOver) {

            if (this.x + velocidadeX < 15) {

                moverPara(this.x + velocidadeX,this.y + velocidadeY);

                engine.velocidadeDaLinha[linha] = 5;

            } else if (this.x + velocidadeX > 160) {

                moverPara(this.x + velocidadeX,this.y + velocidadeY);

                engine.velocidadeDaLinha[linha] = -5;

            } else {

                moverPara(this.x + velocidadeX,this.y + velocidadeY);

            }

           

            if (this.y > 155) {

                engine.gameOver = true;

            }

 

            if (counterY++ > 5) {

                velocidadeY = 1;

                counterY = 0;

            } else {

                velocidadeY = 0;

            }

 

            if ((random.nextInt() & 127) < 10) {

                if (engine.enemyNumeroDeBullets < Engine.ENEMY_MAX_BULLETS) {

                    engine.setBullet(this);

                    engine.enemyNumeroDeBullets++;

                }

            }

        } else if (explodindo) {

            if (counter++ > 4) {

                explodindo = false;

            }

        }

    }

    

O disparo do inimigo foi feito de forma bastante simples. Pega-se um número aleatório (gerado pela função random.nextInt()) e verifica-se se os 7 bits menos significativos são menores que 10. Caso positivo, o inimigo coloca uma bala em jogo (se o número máximo de balas na tela ainda não tiver sido alcançado).

A classe Bullet possui dois atributos específicos: velocidadeY e uma referência ao objeto Sprite que a disparou (dono). A variável velocidadeY muda conforme o sprite que a disparou (se o jogador disparou, a bala sobe, senão, a bala desce). É importante observar que a classe Bullet não tem todos os seus parâmetros inicializados pela construtora - ao invés disso foi utilizado um método setBullet() para inicializar estes parâmetros. Isto porque os objetos Bullet são reutilizados dinamicamente, tendo sido criados no início do jogo e depois realocados para uso sob demanda, para evitar a criação de novos objetos durante o jogo. Essa reutilização traz duas vantagens: aumenta a performance do jogo, evitando chamadas ao new(); e diminui a dependência do código com o Garbage Collector, pois você não precisa que ele destrua os objetos para liberar memória, você mesmo controla o uso desses objetos.

O comportamento da bala é definido pelo Sprite que a disparou. Se ela foi disparada por um inimigo, sua velocidadeY será positiva (isto é, ela estará descendo), enquanto se ela tiver sido disparada pelo jogador esta velocidade será negativa (isto é, ela estará subindo). O método atualizar() da classe Bullet faz o movimento da bala e detecta se ela saiu da tela de jogo, momento no qual ela é desativada e o objeto se torna disponível para reuso.

Engine

O Engine é a classe responsável pela lógica do jogo. Todos os outros objetos do jogo se relacionam através dele como mostra o diagrama de classes da Figura 8.

diagrama-classe
(Clique aqui para ver a figura em tamanho real)

Figura 8. Diagrama da classe Engine e os outros objetos do jogo.

Existem algumas constantes que configuram o jogo (número de inimigos na tela, máximo de balas, entre outros). A criação dessas constantes facilita um ajuste posterior no jogo, somente modificando seu valores. Por exemplo, para aumentar a quantidade de inimigos por linha, é necessário somente mudar o valor inicial da constante ENEMIES_PER_LINE.

O método iniciaJogo() é responsável por criar os objetos que serão usados no jogo. Basicamente, ele cria um objeto Personagem, um array de Bullet e um array de Enemy. Além disso, ele inicia as variáveis do jogo como numero de balas do inimigo, quantidade de inimigos e a variável booleana de gameover. Depois disso, ele passa o display para o GameCanvas e inicia a thread do jogo, por uma chamada ao método start() de GameCanvas. O codigo desse metodo é mostrado na Listagem 6.

Listagem 6. Código do método iniciaJogo() da classe Engine.


public void iniciaJogo() {

       

        gameOver = false;

        numeroDeInimigos = MAX_ENEMIES;

        enemyNumeroDeBullets = 0;

       

        jogador = new Personagem(mobileInvadersMidlet,this);

       

        for (int i = 0 ; i < MAX_BULLETS ; i++) {

            bullets[i] = new Bullet(mobileInvadersMidlet,this);

        }

       

        for (int k = 0; k < LINES_OF_ENEMIES ; k++) {

            for (int j = 0; j <  ENEMIES_PER_LINE ; j++) {

                enemies[j + k * ENEMIES_PER_LINE] = new Enemy( 10 + 20*j,10 + 20*k,k,mobileInvadersMidlet,this,random.nextLong());

            }       

        }

               

        mobileInvadersMidlet.setDisplayable(mobileInvadersMidlet.gameCanvas);

        mobileInvadersMidlet.gameCanvas.start();

    }
    

O método turnoDeJogo() da classe engine é chamado pelo GameCanvas todo turno para atualizar os objetos ativos e detectar a colisão. A sequência deste método é bastante simples. Inicialmente, ele atualiza os objetos ativos (na ordem – personagem, balas, inimigos) por uma chamada ao método atualizar() destes objetos. Em seguida, ele checa se a direção de movimento horizontal de cada linha de inimigos é a mesma para cada um e, se não for, modifica o sinal da velocidade dos demais inimigos ativos nesta linha. Por fim, ele faz a checagem das colisões entre as balas disparadas pelo jogador com os inimigos e entre as balas disparadas pelos inimigos com o jogador. Note que não é feita a checagem entre as balas disparadas pelos inimigos com os outros inimigos – os inimigos não podem matar uns ao outros! Se uma bala disparada pelo jogador atingiu um inimigo, tanto a bala quanto o inimigo são destruídos. Se uma bala disparada pelo inimigo atingir o jogador, o jogo termina. Toda essa sequência de passos mostrados na Listagem 7 (bem como o repaint da tela) é feita a cada turno de jogo, como pode ser visto mais adiante no método run() da classe GameCanvas na Listagem 8.

Listagem 7. Código do método turnoDeJogo() da classe Engine.

                public void turnoDeJogo() {

       

                    jogador.atualizar();
            
                   
            
                    for (int i = 0 ; i < MAX_BULLETS ; i++) {
            
                        if (bullets[i].ativo) {
            
                            bullets[i].atualizar();
            
                        }
            
                    }
            
                   
            
                    for (int j = 0; j < MAX_ENEMIES ; j++) {
            
                        if (enemies[j].ativo || enemies[j].explodindo) {
            
                            enemies[j].atualizar();
            
                        }
            
                    }
            
                   
            
                    for (int k = 0; k < LINES_OF_ENEMIES ; k++) {
            
                        for (int j = 0; j < ENEMIES_PER_LINE ; j++) {
            
                            enemies[j + k * ENEMIES_PER_LINE].velocidadeX = velocidadeDaLinha[k];
            
                        }       
            
                    }
            
                   
            
                    for (int i = 0 ; i < MAX_BULLETS ; i++) {
            
                        if (bullets[i].ativo) {
            
                            if (bullets[i].dono instanceof Personagem) {
            
                                for (int j = 0; j <
                                 MAX_ENEMIES ; j++) {
            
                                    if (enemies[j].ativo) {
            
                                        if (bullets[i].colide(enemies[j])) {
            
                                            bullets[i].killBullet();
            
                                            enemies[j].killEnemy();
            
                                        }
            
                                    }
            
                                }
            
                            } else {
            
                                if (bullets[i].colide(jogador)) {
            
                                    gameOver = true;
            
                                }
            
                            }
            
                        }
            
                    }
            
                   
            
                }
            

O método setBullet(Sprite dono) é chamado pelos sprites na tela quando eles querem disparar uma bala. Isso foi necessário para facilitar o reaproveitamento dos objetos Bullet, como descrito anteriormente.

GameCanvas

Durante o jogo uma grande quantidade de objetos são renderizados na tela e necessitam também serem controlados (como a movimentação do personagem, animação da bala, movimentação dos inimigos, explosão do inimigo). Além das operações referentes aos objetos na tela, tarefas como a checagem dos comandos do teclado e a comunicação com um servidor ou outro aparelho (no caso de um jogo multi-player) devem ser também realizadas. Contudo, o usuário deve ter a impressão de que tudo isso está sendo feito em paralelo, ou seja, ao mesmo tempo. Uma das técnicas de programação que podem ser usadas para atingir tal objetivo é a técnica de Multithreading (ver Nota 4).

Nota 4. Thread Uma thread é um caminho tomado por um programa durante a execução. Executando por vários caminhos, uma aplicação é mais rápida e mais flexível.

Ao menos três threads distintas devem ser implementadas em um jogo: (1) uma para checar a entrada do usuário, (2) uma para redesenhar a tela e (3) uma para manipular os sprites. Para programadores J2ME essa tarefa é facilitada pois as duas primeiras threads já são automaticamente implementadas pela classe Canvas (e também pela classe FullCanvas, que foi a de fato utilizada). Essa implementação é o que provoca a chamada do método keyPressed toda vez que uma tecla é pressionada, e foi utilizada para capturar os comandos do usuário. Isso facilita, pois você não tem que criar um código para escutar a entrada do teclado. A outra thread é a que repinta a tela quando o método repaint() é chamado.

A última thread, de manipulação dos sprites, deve ser implementada pelo programador. Uma forma de se fazer isso é implementando a interface Runnable. Essa interface requer que a classe que a implementa sobrescreva o método run(). A implementação dessa thread está descrita na listagem 8:

Listagem 8. Código para iniciar a thread e o método run(). Esse segmento encontra-se na classe

        public static final int TICK = 80;

 

        //Método run da thread do jogo.
        
        //Chama o método turnoDeJogo para atualizar os sprites.
        
        //Repinta a tela.
        
        //Espera o tick para manter o fps constante.
        
        public void run() {
        
                while(running) {
        
                    inicio = System.currentTimeMillis();
        
                    keyLocked = false;
        
                    engine.turnoDeJogo();
        
                    repaint();
        
                    this.serviceRepaints();
        
                    tempo = System.currentTimeMillis() - inicio;
        
                    if (tempo > TICK) {
        
                        continue;
        
                    } else {
        
                        try {
        
                            Thread.sleep(TICK - tempo);
        
                        } catch (InterruptedException ie) {
        
                        }   
        
                    }
        
                }
        
            }
        
         
        
        //Método start para criar a thread do jogo.
        
        public void start() {
        
                this.gameThread = new Thread(this);
        
                running = true;
        
                gameThread.start();      
        
                gameThread.setPriority(Thread.MAX_PRIORITY);
        
            }    
            

A chamada ao método start() do objeto gameThread chama o método run(), sobrescrito pelo programador e inicia a thread. O método run fica rodando em loop, e em cada turno faz a sequência: chama o método turnoDeJogo() do Engine, repinta a tela, e espera. Quando o jogo entra em pausa (por uma Incoming Call ou por opção do jogador) a variável running se torna false, parando a thread do jogo e toda a movimentação dos sprites. Quando o jogo voltar (o jogador despausar o jogo) esta thread de jogo é criada novamente por uma nova chamada ao método despausar() da classe GameCanvas (mostrado na Listagem 8), reiniciando o jogo.

FPS

A técnica utilizada para garantir uma quantidade de frames por segundo é simples. No começo do método run() é obtido o tempo atual em milissegundos, e após todas as rotinas serem executadas, o tempo é obtido novamente. Se a diferença for menor que a janela de tempo predefinida, espera-se o tempo restante.

Se o seu jogo estiver sempre acima dessa janela, deve-se otimizar o programa. Essa janela pode ficar definida como turno de jogo. No nosso caso usamos uma janela de 80 milissegundos que é aproximadamente 12 quadros por segundos (FPS).

Estabilidade do jogo

Uma coisa que você deve ter em mente é que o celular é um aparelho diferente de um videogame. Apesar de poder rodar jogos, ele não foi feito exclusivamente para isso, nem tampouco isso é a prioridade de seu processador (mesmo no N-Gage, apesar de ser difícil acreditar nisso). Existem eventos de rede que têm maior prioridade sobre os aplicativos, obrigando o programador a prever pelo menos o evento de maior importância: Incoming Call (recebimento de chamada).

No momento do recebimento de uma ligação os aparelhos multitarefa, por exemplo, permitem que o foco do programa seja perdido, porém a aplicação não é finalizada e fica rodando em background. Outros tipos de aparelhos precisam fechar a aplicação para receber a chamada, e isso será realizado sem que o usuário seja questionado sobre a opção de salvar o estado atual do jogo. Você deve saber como é o comportamento do aparelho para o qual você está programando para definir sua estratégia para lidar com isso.

No nosso caso, os aparelhos série 60 são multitarefa, temos apenas que terminar a thread do jogo para diminuir o consumo de energia e processamento e, parar a movimentação dos sprites (para que o jogador não se depare com uma tela de Game Over quando retornar da ligação). Quando o GameCanvas perde o foco, o método hideNotify() é chamado. A Listagem 9 mostra esse método e os métodos pausar() e despausar().

Listagem 9. Métodos para lidar com eventos externos. Esse segmento encontra-se na classe GameCanvas.

            //Quando o canvas volta a ter o foco, não faz nada. Se estiver pausado,

            //espera o jogador pressionar o botão de pause.
        
            protected void showNotify() {
        
               
        
            }
        
           
        
            //Pausa o jogo quando o canvas perde o foco.
        
            protected void hideNotify() {
        
                pausar();
        
            }
        
           
        
            //Para a thread para pausar o jogo.
        
            protected void pausar() {
        
                running = false;
        
            }
        
           
        
            //Reinicia a thread para voltar o jogo.
        
            protected void despausar() {
        
                start();
        
            }
        

Como melhorar o jogo

Inicialmente, a colisão do sprite do Personagem pode ser melhor elaborada. Se você jogar algumas vezes, irá perceber que às vezes a nave do jogador é atingida sem que o tiro realmente esteja sobreposto a nave. Isso porque a detecção da colisão foi feita utilizando um retângulo completo. Na Figura 9 damos algumas idéias de como poderia ser feito para melhorar este efeito.

implementar-regiao-colisao
Figura 9. Formas de implementar a região de colisão. A forma utilizada é a primeira (à esquerda).

A inteligência artificial do jogo também poderia ser melhorada. As naves inimigas poderiam atirar apenas se a nave do jogador estivesse na mira, ou, ainda melhor, poderiam tentar cercar a nave do jogador com tiros em ambos os lados, elaborando uma armadilha.

Você poderia tentar implementar também novas fases, incluindo um sistema de pontuação e incrementando a dificuldade a cada nova fase. Isso pode ser atingido aumentando-se o número de inimigos, o número de balas que eles atiram, aumentando a sua velocidade ou mesmo diminuindo o número máximo de tiros que o jogador pode atirar. Veja que todas essas sugestões não envolvem a criação de novos elementos no jogo, de forma que são bastante simples de se implementar. Entretanto, a criação de novos elementos no jogo não deve ser descartada. Talvez alguns elementos de cenário, novas naves inimigas ou alguns itens possam ser interessantes.

Outras abordagens

A abordagem apresentada aqui (com a utilização de uma classe Sprite) não é a única possível. Um jogo simples como este com certeza poderia ser desenvolvido utilizando programação estruturada (mesmo em Java), criando-se apenas as duas classes necessárias (o Midlet e o GameCanvas) e concentrando tudo nessas classes. Os sprites na tela, ao invés de serem objetos capazes de decidir por si mesmos, poderiam ser representados por um array de posições e gerenciados pela lógica do jogo, implementada também na classe GameCanvas. Com certeza isso funcionaria e, neste exemplo específico, poucos usuários iriam perceber diferenças na implementação. Entretanto, um código elaborado dessa forma tem mais chances de ficar muito complexo, difícil de manter e modificar.

De fato, você já deve ter ouvido falar que “para desenvolvimento de jogos em celular, não deve ser utilizada programação orientada a objetos, preferindo-se a opção da programação estruturada” por criar poucos objetos e economizar processamento e heap. Isso é verdade, mas não se deve tomá-la de forma radical. Não significa que você tenha que criar apenas duas classes em seu programa. Isso significa que é preferível optar por dois atributos de posição (int x e int y) para a classe Sprite do que utilizar um objeto Dimensão que possui dois atributos. A modificação direta de atributos, sem setters e getters, é mais rápida, além de não dificultar o entendimento e manutenção do código. Você deve evitar utilizar objetos desnecessariamente, mas não precisa sacrificar o código por conta disso. Além disso, com o crescente poder de processamento dos aparelhos atuais (os smartphones da série 60 são um bom exemplo), eles são capazes de rodar programas até mesmo com centenas de classes. E isso é fato, nós testamos.

A essa altura, você deve ter notado que falamos muito em “entendimento do código” e “manutenção do código”. Isso porque em jogos mais complexos, é comum que o código cresça e pode acontecer que, ao adicionar um novo elemento, ele afete várias outras partes do código que já haviam sido desenvolvidas, forçando você a rever todo o código. Isso dificulta bastante o processo já complicado do debug. Como o aparelho não tem um console, para você ver a saída do System.out.println(), você deve optar por outras formas de realizar o debug. Embora o emulador suporte o console, facilitando em parte a vida do desenvolvedor, o teste no aparelho tem que ser realizado; você irá notar que é um mundo bastante diferente.

Outra coisa que este entendimento e manutenção afeta é o “e se”. Você fez todo o seu jogo, já está para terminar, e alguém na equipe ou algum play tester fala: “e se a nave se transformasse em um robô gigante pacifista e invencível?”. Pronto. Você já está com o jogo quase pronto e vem alguém com uma idéia que precisaria reestruturar boa parte do jogo novamente. Esses “e ses” podem ser evitados trabalhando bastante na concepção do jogo. No entanto, é natural que novas idéias surjam no decorrer do desenvolvimento. E, embora você tenha que ser bastante cético com relação a elas, não pode ser tão radical, pois pode ser que uma idéia relativamente simples de se implementar melhore bastante a versão final do jogo.

Conclusão

O jogo apresentado é simples, porém é um bom exemplo pois demonstra os conceitos básicos do processo de criação de jogos para celulares. Além disso, mostrou alguns erros que podem ocorrer durante esse processo. Com as dicas, esperamos motivar o leitor a corrigir estes erros e desenvolver seus próprios jogos.

revista
Esse artigo faz parte da revista WebMobile edição 1. Clique aqui para ler todos os artigos desta edição