Cleuton Sampaio
Manual do Indie Game Developer Versão Android e iOS
Manual do Indie Game Developer - Versão Android e iOS CopyrightEditora Ciência Moderna Ltda., 2013 Todos os direitos para a língua portuguesa reservados pela EDITORA CIÊNCIA MODERNA LTDA. De acordo com a Lei 9.610, de 19/2/1998, nenhuma parte deste livro poderá ser reproduzida, transmitida e gravada, por qualquer meio eletrônico, mecânico, por fotocópia e outros, sem a prévia autorização, por escrito, da Editora. Editor: Paulo André P. Marques Produção Editorial: Aline Vieira Marques Assistente Editorial: Amanda Lima da Costa Capa: Carlos Arthur Candal Diagramação: Equipe Ciência Moderna Várias Marcas Registradas aparecem no decorrer deste livro. Mais do que simplesmente listar esses nomes e informar quem possui seus direitos de exploração, ou ainda imprimir os logotipos das mesmas, o editor declara estar utilizando tais nomes apenas para fins editoriais, em benefício exclusivo do dono da Marca Registrada, sem intenção de infringir as regras de sua utilização. Qualquer semelhança em nomes próprios e acontecimentos será mera coincidência. FICHA CATALOGRÁFICA MELO JUNIOR, Cleuton Sampaio de. Manual do Indie Game Developer - Versão Android e iOS Rio de Janeiro: Editora Ciência Moderna Ltda., 2013.
1. Informática 2. Linguagem de Programação 3. Jogo eletrônico I — Título ISBN: 978-85-399-0460-2
Editora Ciência Moderna Ltda. R. Alice Figueiredo, 46 – Riachuelo Rio de Janeiro, RJ – Brasil CEP: 20.950-150 Tel: (21) 2201-6662/ Fax: (21) 2201-6896 E-MAIL:
[email protected] WWW.LCM.COM.BR
CDD 0001.642 005.133 323.43
07/13
Dedico este livro à minha esposa, Fátima e aos meus filhos: Rafael, Tiago, Lucas e Cecília.
“Escolha uma profissão que você ame, e nunca terá que trabalhar um só dia em sua vida.” Confúcio, filósofo e pensador Chinês: (551 a.c – 479 a.c)
Sumário Capítulo 1 - Introdução����������������������������������������������������������������������1 Sobre as plataformas�������������������������������������������������������������������������������� 1 Sobre o código-fonte�������������������������������������������������������������������������������� 2
Capítulo 2 - Revisão de conceitos������������������������������������������������������3
Tipos básicos de Games��������������������������������������������������������������������������� 3 Jogos de ação������������������������������������������������������������������������������������� 7 Macro funções de um game��������������������������������������������������������������������� 8 Model / View / Controller���������������������������������������������������������������� 10 Objetos e elementos típicos de um Game���������������������������������������������� 11 Jogabilidade������������������������������������������������������������������������������������� 13 Fatores de sucesso de um Game������������������������������������������������������������ 14
Capítulo 3 - Kit de ferramentas para criação de games����������������17
Ferramentas gráficas������������������������������������������������������������������������������ 17 Imagens�������������������������������������������������������������������������������������������� 17 Transparência e canal alpha������������������������������������������������������������������� 18 Unidades de medida������������������������������������������������������������������������ 20 Arquivos de imagem������������������������������������������������������������������������ 21 Ferramenta para “Sketch”���������������������������������������������������������������� 22 Ferramentas para desenho Raster���������������������������������������������������� 23 Ferramentas para desenho vetorial�������������������������������������������������� 24 Comparação entre renderização raster e vetorial����������������������������� 25 Game engines����������������������������������������������������������������������������������������� 28 HTML 5 + Javascript����������������������������������������������������������������������� 28 Nativos��������������������������������������������������������������������������������������������� 28 Usar a plataforma nativa������������������������������������������������������������������ 30 Ambientes multiplataforma������������������������������������������������������������������� 30 Prototipadores���������������������������������������������������������������������������������������� 31 Bibliotecas auxiliares����������������������������������������������������������������������������� 31
Capítulo 4 - Prototipação do game��������������������������������������������������33
Usando o Codea������������������������������������������������������������������������������������� 34 Um tutorial rápido em Codea���������������������������������������������������������� 35 Criando o protótipo do “AsteroidNuts” no Codea�������������������������� 39
Sumário — VII
Usando o “Processing”�������������������������������������������������������������������������� 49 Um tutorial rápido no “Processing”������������������������������������������������ 51 Criando o protótipo do “AsteroidNuts” com o “Processing”���������� 54 Crie vários protótipos���������������������������������������������������������������������������� 64
Capítulo 5 - Física de games�������������������������������������������������������������65
Os primórdios���������������������������������������������������������������������������������������� 66 Conceitos básicos����������������������������������������������������������������������������������� 66 Aceleração e movimento����������������������������������������������������������������� 68 Colisão��������������������������������������������������������������������������������������������� 70 Deteção de colisão��������������������������������������������������������������������������� 72 Engines de física������������������������������������������������������������������������������������ 76 Bullet Physics Library��������������������������������������������������������������������� 76 Chipmunk physics engine���������������������������������������������������������������� 77 Box2D ��������������������������������������������������������������������������������������������� 77 Física com Box2D��������������������������������������������������������������������������������� 78 Preparando o laboratório����������������������������������������������������������������� 79 O laboratório Java���������������������������������������������������������������������������� 80 Normalização e ajuste para renderização���������������������������������������� 85 Fundamentos do Box2D������������������������������������������������������������������ 87 Força e atrito������������������������������������������������������������������������������������ 93 Corpos circulares����������������������������������������������������������������������������� 97 Rotação dos corpos�������������������������������������������������������������������������� 98 Desenhando corpos como polígonos��������������������������������������������� 100 Desenhando corpos como imagens����������������������������������������������� 102 Torque e impulso��������������������������������������������������������������������������� 107 Deteção de colisão������������������������������������������������������������������������� 110 Juntas ou junções��������������������������������������������������������������������������� 113 Usando o Box2D em plataformas móveis������������������������������������������� 123 Box2D no Android������������������������������������������������������������������������ 123 Box2D no iOS������������������������������������������������������������������������������� 132
Capítulo 6 - Renderização com OpenGL ES 2.0��������������������������143 OpenGL ES������������������������������������������������������������������������������������������ 144 Fundamentos���������������������������������������������������������������������������������������� 146 Coordenadas dos vértices e da textura������������������������������������������� 146 Buffers������������������������������������������������������������������������������������������� 150
VIII ������������������������������������������������������� —������������������������������������������������������ Manual do Indie Game Developer - Versão Android e iOS
Programas de sombreamento��������������������������������������������������������� 152 Matrizes����������������������������������������������������������������������������������������� 154 Exemplo utilizando Android e projeção perspectiva��������������������������� 159 Entendendo a GLSurfaceView������������������������������������������������������ 160 A implementação��������������������������������������������������������������������������� 163 Concluindo������������������������������������������������������������������������������������ 175 Exemplo utilizando iOS e projeção perspectiva���������������������������������� 176 Entendendo o GLKit���������������������������������������������������������������������� 176 Criando uma aplicação OpenGL ES���������������������������������������������� 178 A implementação��������������������������������������������������������������������������� 179 Achatando as coisas����������������������������������������������������������������������������� 190 Implementação em Android���������������������������������������������������������� 190 Implementação em iOS����������������������������������������������������������������� 193
Capítulo 7 - Framework de Física e Renderização����������������������195 Um framework básico�������������������������������������������������������������������������� 196 O esquema XML do arquivo de modelo de game������������������������� 197 Proporcionalidade dos GameObjects�������������������������������������������� 200 Coordenadas e VBOs��������������������������������������������������������������������� 201 Movimento e rotação de objetos���������������������������������������������������� 204 Atualização do mundo Box2D������������������������������������������������������ 205 Renderização do modelo��������������������������������������������������������������� 206 Mipmaps���������������������������������������������������������������������������������������� 207 Uso do Framework������������������������������������������������������������������������������ 209 Conclusão�������������������������������������������������������������������������������������������� 212
Capítulo 8 - Técnicas Comuns em Games������������������������������������215 Como exibir um HUD������������������������������������������������������������������������� 215 Aumentando o framework de game���������������������������������������������� 220 Integrando o game em uma aplicação móvel�������������������������������������� 226 Plataforma Android����������������������������������������������������������������������� 227 Plataforma iOS������������������������������������������������������������������������������ 231 Tempo e movimento���������������������������������������������������������������������������� 233 Game Loop������������������������������������������������������������������������������������ 234 Movimento������������������������������������������������������������������������������������ 247 Efeito de paralaxe�������������������������������������������������������������������������������� 256 Técnicas de paralaxe���������������������������������������������������������������������� 257 Exemplo de paralaxe com camadas de GameObjects������������������� 258
Sumário — IX
Implementação Android���������������������������������������������������������������� 261 Implementação iOS����������������������������������������������������������������������� 267 Games do tipo plataforma�������������������������������������������������������������������� 273 Implementação Android���������������������������������������������������������������� 276 Implementação iOS����������������������������������������������������������������������� 278 Sistemas de partículas�������������������������������������������������������������������������� 280 Composição����������������������������������������������������������������������������������� 280 Um exemplo���������������������������������������������������������������������������������� 281 Implementação em Android���������������������������������������������������������� 283 Implementação iOS����������������������������������������������������������������������� 291 Conclusão�������������������������������������������������������������������������������������������� 296
Capítulo 9 - Vamos Criar um Game����������������������������������������������297
Limitações�������������������������������������������������������������������������������������������� 299 Licença de uso do código-fonte����������������������������������������������������������� 299 A concepção����������������������������������������������������������������������������������������� 300 Jogos casuais���������������������������������������������������������������������������������������� 301 Jogabilidade����������������������������������������������������������������������������������������� 301 Implementação Básica������������������������������������������������������������������������� 302 I18N e L10N���������������������������������������������������������������������������������� 302 Alterações no modelo do game����������������������������������������������������� 303 Alterações na carga do nível corrente�������������������������������������������� 307 Alterações no Game Loop������������������������������������������������������������� 309 Colisões����������������������������������������������������������������������������������������� 311 Registro de tempos������������������������������������������������������������������������ 315 Gestão de memória no iOS������������������������������������������������������������������ 323 Uso do GLKBaseEffect����������������������������������������������������������������� 324 Procure Memory Leaks����������������������������������������������������������������� 327 Verifique se está realmente liberando a memória�������������������������� 328 Conclusão�������������������������������������������������������������������������������������������� 332 Possíveis melhorias no game��������������������������������������������������������������� 333
Capítulo 1 Introdução Quando eu comecei a desenvolver games, li muitos livros sobre o assunto e fiz muitas pesquisas no Google. Muitos dos problemas que eu passei no início tive que batalhar muito para resolver sozinho. Dessa forma, reuni um conjunto de técnicas ao longo do meu aprendizado, que são úteis até hoje. Durante esse aprendizado, notei que é muito difícil encontrar guias práticos de uso, com exemplos simples, o que nos força a ler muito e escrever muitas provas de conceito, perdendo tempo neste processo. Então, resolvi colocar no papel tudo o que eu havia aprendido, e que poderia ser útil para outras pessoas. Assim, nasceu a ideia deste livro, que é um guia prático com soluções para os problemas comuns no desenvolvimento de games móveis. Eu queira algo mais que um “recipe book” (livro de receitas), porém menos que uma referência completa. Um livro que você consegue ler rapidamente e consultar sempre que precisar, com inúmeros exemplos em código-fonte, além de um game completo. Neste livro, eu assumo que você, leitor, leitora, é um desenvolvedor experiente e que deseja criar games para plataformas móveis, especialmente: Android e iOS. Apesar de já haver escrito livros introdutórios sobre o assunto, este é um livro para profissionais que desejem entrar no lucrativo negócio de desenvolvimento de games, tornando-se um “Indie Game Developer” (desenvolvedor independente de games). Ao longo do livro, você construirá um framework simples, porém abrangente, e que pode ser utilizado para criar games móveis biplataforma (Android e iOS) rapidamente. O game exemplo do livro “Bola no Quintal”, foi feito em apenas 1 semana para as duas plataformas! A melhor maneira de aproveitar todo o conteúdo é ler com a “mão na massa”, rodando todos os exemplos de código.
Sobre as plataformas Este livro apresenta exemplos em Android e iOS, logo, você deverá ter instalados os dois kits de desenvolvimento. Nada impede que você opte por uma das duas plataformas, pois o livro não obriga você a conhecer ambas. A
2 — Manual do Indie Game Developer - Versão Android e iOS
plataforma Android utilizada é a última versão disponível (4.2, API = 17), porém, os exemplos podem ser executados em qualquer dispositivo com versão “Gingerbread” (maior ou igual a 2.3). A plataforma iOS utilizada é também a última versão disponível (6.1), com Xcode 4.5. Se optar por utilizar as duas plataformas, será necessário utilizar um computador Mac / OS X, com versão mínima “Mountain Lion”, podendo desenvolver para Android e iOS.
Sobre o código-fonte Todo o código-fonte do livro está disponível para download, em formato zip. Você poderá encontrá-lo nos seguintes locais: • Site da editora: http://www.lcm.com.br, procure pelo título do livro; • Site específico deste livro: http://www.indiegamerbrasil.com; • Site “The Code Bakers”: http://www.thecodebakers.com. Se tiver alguma dúvida ou dificuldade para instalar os projetos ou sobre o livro, por favor me contate:
[email protected]
Capítulo 2 Revisão de conceitos Games... Que coisa apaixonante! São capazes de despertar emoções, tanto negativas como positivas e são obras de arte “vivas”, que deixamos para a posteridade. Se você nunca criou um game, não sabe quão boa é a sensação de saber que as pessoas passam horas se divertindo com a sua criação. Se alguém me diz que criou um compilador, ou um sistema operacional, certamente vai me impressionar. Agora, se alguém me diz que criou um Game, certamente vai chamar minha atenção imediatamente. Para mim, o “ultimate fight” de todo programador é criar um Game. Isto é o que diferencia os bons dos comuns. Para mim, desenvolver um Game é a maior prova de competência para um programador. É realmente um desafio muito grande, pois existem muitos aspectos a serem abordados. Um game é um projeto de software complexo, com vários recursos, de áreas diferentes envolvidas.
Tipos básicos de Games Existe mais de uma maneira de classificarmos um game, dependendo do ponto de vista. Se estivermos falando sobre visualização, podemos começar com: • Games 2D: aqueles em que jogamos nos movendo apenas em duas direções. Podem até utilizar recursos para dar “ilusão” de profundidade (como “paralaxe”, visão isométrica etc), mas nos movemos em um plano. Existem jogos que permitem mudar de plano (e perspectiva), como o “Fez” (http://en.wikipedia.org/wiki/Fez_(video_game)), mas ainda são basicamente 2D nos movimentos; • Games 3D: aqueles em que jogamos nos movendo em seis graus de liberdade (http://en.wikipedia.org/wiki/Six_degrees_of_freedom), com movimentos possíveis de: aproximação / afastamento (eixo “z”), direita / esquerda (eixo “x”), cima / baixo (eixo “y”), e rotações em cada eixo. Alguns games 3D não permitem isso tudo, restringindo as rotações, por exemplo; Há uma certa confusão quando falamos em “Games 3D”. Algumas pessoas confundem este conceito com ilusão 3D. Na verdade, o conceito que a literatura e os “gamers” mais aceitam é o dos “6 graus de liberdade”.
4 — Manual do Indie Game Developer - Versão Android e iOS
Ilustração 1: Seis graus de liberdade (autor: Horia Ionescu, Wikipedia http://en.wikipedia.org/wiki/Six_degrees_of_freedom)
A figura anterior nos mostra os movimentos possíveis em um verdadeiro Game 3D. Podemos: correr, pular, saltar, rolar (nos três eixos) etc. Alguns games limitam o movimento somente aos três eixos, evitando rotações, porém, mesmo assim, não se limitam a um plano, como os Games 2D. Também podemos classificar um Game quanto ao seu tipo de projeção, que é a maneira como o modelo é projetado na tela.
Ilustração 2: Projeção 3D
Se for um Game 3D, isto é mais crítico ainda. A tradução do modelo “espacial” para o modelo 2D da tela pode ser feito de várias maneiras: • “Projeção Ortográfica” (ou ortogonal): é a ilusão de 3D criada ao criarmos projeções ortogonais ao plano, ou seja, não há diferença de tamanho entre objetos próximos e objetos distantes. Desta forma, mostramos os elementos em um ângulo diferente do que veríamos apenas de cima, ou de lado. Evita os cálculos complexos de projeção 3D e mantém uma boa ilusão para o jogador.
Capítulo 2 - Revisão de conceitos — 5
• “Visão Isométrica”: não é uma projeção, mas uma forma de mostrarmos os objetos. Neste tipo de visão, rotacionamos um pouco os eixos para mostrar mais detalhes dos objetos, criando a ilusão 3D. Neste caso, o ângulo entre os eixos (x, y, z) no plano é igual, ou seja: 120 graus. Normalmente, a visão isométrica é utilizada com projeção ortográfica. Era muito comum nos anos 80 e 90, embora alguns jogos ainda a utilizem. Alguns exemplos famosos que usam visão isométrica: Q*Bert (Gottlieb), Civilization II (MicroProse), Diablo (Blizzard); • “Projeção Perspectiva”: vem do desenho tradicional, no qual as partes mais próximas parecem maiores do que as mais distantes. Também é conhecida como perspectiva cônica. Dão maior ilusão de realidade aos games, porém exigem maior complexidade de programação e poder de processamento. Os jogos mais modernos, como: Battlefield, Assassin’s Creed, Halo e outros apresentam visão em perspectiva;
Ilustração 3: Projeções 2D de um cubo
É claro que procurei simplificar ao máximo o conceito de projeção 3D, mas é apenas para facilitar o entendimento. Se quiser saber mais, recomendo um bom livro, como o “Mathematics for Game Developers”, de Christopher Tremblay (Amazon). Porém, existem bons artigos introdutórios na Wikipedia: • Perspectiva (gráfica): http://pt.wikipedia.org/wiki/Perspectiva_(gr%C3%A1fica); • Isometria: http://pt.wikipedia.org/wiki/Isometria_(geometria); • Isometric graphics in video games and pixel art: http://en.wikipedia. org/wiki/Isometric_graphics_in_video_games_and_pixel_art; Também existem classificações de games quanto às suas características, algumas delas são: • “Arcade”: este termo é mal interpretado, mas sua origem vem das máquinas de jogos, conhecidas aqui como “Fliperama”. Jogos “arcade” possuem regras simples, partidas rápidas, com pontuação,
6 — Manual do Indie Game Developer - Versão Android e iOS
vidas, visão Isométrica etc). Exemplos de “arcades” clássicos: “Space invaders” e “Pac-Man”. Os jogos “arcade” evoluíram e hoje estão disponíveis em várias plataformas, incluindo mobile; • Jogos de Ação: desafiam o jogador fisicamente, seja por coordenação motora, visão de movimento ou tempo de reação. Possuem várias subcategorias, como: plataforma, tiro e luta. Exemplos clássicos: “River raid” (Atari), “Sonic” (SEGA), “Super Mario (Nintendo) e outros mais modernos, como: “Angry Birds” (Rovio), “Jetpack joyride” (Halfbrick). Na verdade, “arcades” são jogos de ação, só que com regras e visual mais simples; • Aventura (adventure): jogos que desafiam a inteligência do jogador, colocando-o em determinado mundo, com uma missão a cumprir. Nos primórdios da computação pessoal, os jogos de aventura eram simples, muitas vezes textuais, ou com poucos gráficos. Eu gosto de citar os exemplos do Renato Degiovani, como: “Serra Pelada” e “Angra-I”. Hoje em dia, muitos jogos de aventura também misturam características de jogos de ação, como os “Shooters”; • Role Playing Game (RPG): são derivados dos famosos jogos de RPG textuais, como “Dungeons & Dragons”. Na verdade, são jogos de aventura, nos quais o jogador assume um papel e uma vida paralela em um universo virtual. A principal diferença para os jogos de aventura tradicionais é a duração do jogo, que pode ser indeterminada. Hoje em dia, os jogos RPG ganharam versões multiusuário online, como os MMORPG – Massively Multiuser On-line RPG (RPGs on-line massivamente multiusuários). Exemplos: “World of Warcraft” (Blizzard) e “Ragnarök” (Gravity Corp); • Tile based games: podem ser jogos de ação ou RPG, nos quais os personagens se movem em um “tabuleiro”, de casa em casa. Existem vários padrões e ferramentas para criar “Tile Based Games”, como o padrão TMX (https://github.com/bjorn/tiled/wiki/TMX-Map-Format) e TiledEditor (http://www.mapeditor.org/). Alguns RPGs e jogos de aventura são também “Tile Based”; • Menu games: são jogos de simulação e RPG, com interface baseada em escolha. Parecem muito com os jogos de cartas, como: “Magic”. Você tem uma série de atributos e vai desafiando os outros jogadores, acumulando vitórias ou amargando derrotas. Um bom exemplo é o “Mafia Wars” (Zynga);
Capítulo 2 - Revisão de conceitos — 7
• Simuladores: simulam veículos ou atividades. Existem simuladores de voo (Microsoft Flight Simulator), de carro (Gran Turismo – Sony), de cidade (SimCity – Maxis). São mais difíceis e desafiadores; • Estratégia: são uma mistura de simulação e RPG, no qual o jogador tem que conquistar um objetivo, geralmente através da guerra. Exemplos: “Age of Empires” (Microsoft) e “Civilization” (Micro Prose); Outra classificação importante é quanto ao público-alvo do Game, por exemplo: • Infantis: jogos para crianças, geralmente não alfabetizadas ou em fase de alfabetização. São simples e visuais; • Jogos para meninas: as meninas estão cada vez mais “tomando de assalto” o mundo dos games e existem jogos específicos para elas, como: jogos de vestir, jogos de cozinhar, jogos de princesas; • Casual games (jogos casuais): são feitos para quem não é jogador habitual (ou “gamer”). São para pessoas que jogam casualmente, sem ter isto como atividade constante. Podem ser de vários tipos, mas, geralmente, são simples, com poucas regras, fácil jogabilidade e baixo comprometimento (você não tem que criar uma “carreira”). Podem ser quebra-cabeças simples, como: “Where is my water” (Disney) ou jogos de tiro, como “Angry Birds” (Rovio); • Hardcore games: são feitos para jogadores habituais ou “gamers”. Normalmente, exigem um comprometimento maior do jogador, que tem que criar uma “carreira” virtual, que pode ser comunicada em redes sociais de games. Geralmente possuem várias sequências, como: “Assassin’s Creed” (Ubisoft), “Battlefield” (EA Games) ou “Gran Turismo” (Sony);
Jogos de ação O objetivo principal deste trabalho são jogos de ação. Em primeiro lugar, porque todos os elementos clássicos (Player Object, NPC, Game loop) são encontrados neles e, em segundo lugar, porque são uma “porta de entrada” para o desenvolvimento de games. Os jogos de ação podem ser divididos também em subcategorias, como: • Plataforma: são jogos 2D com ilusão 3D, nos quais o jogador se move entre plataformas, de diversas altitudes, pulando, correndo e saltando. Ele pode ter que enfrentar desafios físicos, como abismos,
8 — Manual do Indie Game Developer - Versão Android e iOS
ou mesmo adversários. Exemplos são: “Sonic” (SEGA), “Super Mario” (Nintendo) e, mais modernamente, “Super Meat Boy” (Team Meat); • Tiro: seu objetivo é matar inimigos atirando diretamente neles ou derrubando-os. Podem ser simples como o “Angry Birds” (Rovio), ou complexos, como o “Doom” (Id Software / Nerve Software). Entre os jogos de tiro, existem os “First Person Shooters”, nos quais a visão é a do jogador (em primeira pessoa), ou “Third Person Shooters”, nos quais a visão é externa ao jogador; • Luta: são jogos nos quais o jogador é desafiado a derrotar lutadores, que podem ser totalmente virtuais, ou avatares controlados por outras pessoas. Alguns exemplos são: “Street Fighter” (Capcom) e “Mortal Kombat” (Midway games); Jogos de ação são baseados em tempo, ou seja, a cada fatia de tempo a posição do jogador (e das balas) muda, sendo atualizada constantemente. Quando o processamento de uma fatia de tempo demora demais, acontece o efeito de “Lagging”, que é quando o jogador percebe a lentidão do jogo em determinados momentos. O “Lag” pode ter diversas causas, como a latência da rede, em caso de jogos multiusuário, ou mesmo a lentidão da renderização de frames. O “Lag” pode causar grande insatisfação no usuário, compromentendo o sucesso do Game. Devido a esta característica de sensibilidade ao tempo, jogos de ação exigem muito do programador. É necessário racionalizar os gráficos, utilizando ao máximo o poder de processamento de GPU (Graphics Processing Unit), e também empregar recursos de multiprogramação, de modo a aproveitar o paralelismo, afinal, muitos dispositivos móveis são multicore (dotados de mais de uma CPU – ou núcleo).
Macro funções de um game Se pensarmos nas macrofunções de um game, veremos algo como o diagrama seguinte.
Ilustração 4: Macro funções de um game
Capítulo 2 - Revisão de conceitos — 9
Inventário Responsável pelos objetos e propriedades do Game. Os elementos ativos do jogo (personagens ou Player Objects e NPCs) e suas propriedades (quantidade de vidas etc). Pontuação Responsável por acompanhar a pontuação do usuário, suas conquistas e o compartilhamento destas informações. Estratégia Quando o jogo tem caracteres próprios, não manipulados pelo usuário (Non-Player Character ou NPC), é preciso dar alguma inteligência a eles, caso contrário, o jogo será fácil demais. Alguns tipos de jogos dispensam a estratégia, baseando-se apenas em cálculos aleatórios, como: labirintos, por exemplo. Jogos do tipo “Shooting” (2D, 3D, TPS, FPS), quando não estão em modo multiusuário, necessitam de estratégias avançadas, para dar a sensação de realidade ao usuário. Configuração Responsável pela persistência e recuperação de todas as informações de configuração do jogo, como: níveis oferecidos, nível do jogador, conquistas, opções etc. Game loop É o conjunto de comandos repetidos a cada fatia de tempo, responsáveis por movimentar os objetos, atualizando o modelo de dados. Ele também pode, ao final do processamento, invalidar a apresentação, ordenando o redesenho da tela. Em jogos de ação, geralmente existe um Game loop baseado em temporizador, que atualiza a tela em determinadas fatias de tempo (time frame). Alguns tipos de jogos dispensam este recurso. Animação Os caracteres do jogo e até mesmo o cenário podem exibir animações. O personagem pode sorrir, chorar, gritar e o cenário pode mudar. Até mesmo a movimentação dos personagens é tratada por esta função. A animação também envolve a técnica utilizada para desenhar os objetos na tela a cada fatia de tempo passada. Física A macrofunção de física é muito importante em jogos de ação. Ela é responsável por dar realismo ao movimento dos caracteres. Coisas como: impulso, gravidade, movimento e colisão são tratadas por esta função.
10 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Interação Controla a maneira pela qual o usuário interfere no jogo. É responsável por capturar o “input” do usuário e converter em ações no jogo. Comunicação É responsável por comunicar ao usuário o “status” do jogo, e também o resultado de suas ações. Pode manter “energy bars”, contadores, emitir sons, acionar explosões etc. Algumas destas macrofunções podem ser implementadas em parte por bibliotecas de terceiros, como: Box2D e OpenGL ES. Porém, mesmo assim, é necessário um esforço do desenvolvedor para configurar e interagir com elas. Uma função muito importante é a de “Inventário”, que também abrange o modelo de dados do Game.
Model / View / Controller Um Game bem construído é sempre baseado no padrão MVC – Model / View / Controller, no qual os três aspectos da aplicação estão separados: • Model: responsável por manter o estado da aplicação; • View: responsável por ler o estado atual e apresentar ao usuário; • Controller: responsável por alterar o estado da aplicação atualizando o modelo e controlar a atualização da “View”; O modelo de um Game é a sua representação em memória. Normalmente, mantemos referências dos objetos ativos, como: caracteres e cenário. Mas podemos ter variações, como camadas diferentes, para dar efeito de “paralaxe” (http://en.wikipedia.org/wiki/Parallax_scrolling), que é quando os objetos distantes se movem mais lentamente do que os objetos mais próximos, dando ilusão de profundidade.
Ilustração 5: Efeito paralaxe - Wikipedia: http://en.wikipedia.org/wiki/Parallax_scrolling
Capítulo 2 - Revisão de conceitos — 11
Na figura, vemos três camadas de objetos (céu, celeiros e chão), que se movem em velocidades diferentes, dando ilusão de profundidade ao Game. A camada de apresentação (ou View) de um Game é responsável por capturar o estado do modelo e desenhar os objetos em sua posição atual. Existem várias técnicas para desenhar objetos, desde utilizar os próprios “Widgets” nativos do aparelho (Android ImageView ou iOS UIView), ou desenhar dinamicamente os elementos (Android onDraw / iOS drawRect:(CGRect)rect). Finalmente, a camada Controller tem a missão de atualizar o modelo e ordenar que a camada de apresentação atualize a tela. A camada Controller é responsável por executar o “Game loop” e por obter o “input” do usuário. Também deve se comunicar com a macrofunção de “Estratégia” para ver como o jogo deve reagir.
Objetos e elementos típicos de um Game Temos alguns elementos comuns em vários tipos de games, especialmente nos jogos de ação. Por exemplo, sempre existe um objeto que representa o jogador ou que ele está controlando diretamente. Este tipo de objeto é chamado de “Player Object”. Existem casos em que mais de um PO (Player Object) existe ao mesmo tempo, e o jogador consegue mudar o controle de um para outro (Sonic & Tails, Jogos de Futebol etc). Porém, geralmente, o jogador está controlado apenas um único PO a cada momento. O Player Object pode ser uma nave, como no famoso jogo “Asteroids” (Atari – http://en.wikipedia.org/wiki/Asteroids_(video_game)), no qual o jogador tinha uma nave e podia virar e atirar contra os asteroides. Também pode ser um objeto como uma bola, como no jogo para iPad “Labirynth HD” (Illusion Labs AB). No jogo que apresentei em meu último livro, “BueiroBall”, o jogador controlava a mesa, logo, todas as bolas eram POs. Outro tipo de objeto é o NPC – Non Player Character (Caractere ou personagem não controlado pelo jogador). Ele representa o adversário, que pode (ou não) ser dotado de alguma inteligência. Nos jogos multiusuário, o NPC pode ser um PO controlado por outro Jogador, ou pode ser algo controlado pela inteligência do jogo em si. NPCs existem para complicar a vida do jogador, tornando a partida mais divertida e interessante. Quem é que não se lembra das “tartarugas” do “Super Mario”? O ponto mais importante dos NPCs é que eles possuem “movimento” e “comportamento”, ou seja, podem atirar, perseguir, se esconder, explodir ou tomar atitudes, que são controladas pela macro função de “Estratégia” do jogo. Um NPC é sensível ao movimento e à colisão.
12 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Levando em conta a característica de sensibilidade ao tempo, típica dos jogos de ação, um NPC ocupa fatia considerável do processamento e dos recursos. Quanto mais NPCs um jogo tem em determinado momento, maior a quantidade de recursos para atualizá-los e movimentá-los. Balas também podem ser consideradas como NPCs, pois se movimentam e são sensíveis a colisões. Também existem os objetos estáticos, que não são sensíveis a colisões. Objetos estáticos podem até se mover, mas sempre como parte de um cenário. Não são sensíveis a colisões e nem podem ser controlados pelo jogador. Alguns exemplos: cenário, nuvens, árvores distantes, montanhas etc. Em certos casos, os objetos estáticos nem existem, sendo parte da imagem de fundo. Em jogos mais modernos, objetos podem ser acrescentados ao fundo para dar a ilusão de paralaxe. O importante é que objetos estáticos não podem ser afetados pelo PO ou pelos NPCs. Jogos mais modernos permitem que se deforme em algum grau o cenário, destruindo prédios, veículos de fundo etc. Nestes casos, os objetos estáticos estão se comportanto como NPCs, recebendo e se deformando em função de tiros, o que exige um alto poder de processamento da console ou do dispositivo móvel. Game Loop é o núcleo de atualização de um jogo de ação. É nele que ocorre a atualização do “Modelo” e pode também ocorrer a “renderização” da “View” (MVC). Os game loops mais simples podem fazer apenas isto: 1. Verificar se o usuário entrou com algum estímulo; 2. Executar a estratégia pra os NPCs; 3. Mover o Player Object; 4. Mover os NPCs; 5. Verificar colisões; 6. Atualizar inventário; 7. Invalidar a “View”; 8. Dar feedback sonoro. Na verdade, o Game loop constrói um “momento” do jogo, que é apresentado em sequência de modo a dar a ilusão de movimento. Apesar de parecer simples, existem várias considerações importantes a respeito do Game loop. Por exemplo, ele comanda a renderização? Ele é baseado em time-frame? Ele roda em um “Thread” separado? Como lidar com a concorrência? Em alguns casos, o Game loop é independente da fase de “renderização”, e ocorrem em momentos (e até em CPUs) separados. O ideal é que o Game loop seja “enxuto” o suficiente para rodar a cada Time-frame, evitando “Lag”.
Capítulo 2 - Revisão de conceitos — 13
Outro problema típico é a aceleração provocada pelo hardware. Por exemplo, ao jogar em um dispositivo mais veloz, o Game loop fica mais rápido e os NPCs também. É preciso haver um controle de temporização, garantindo um “frame-rate” (taxa de atualização) constante por segundo. Normalmente os games atuam entre 30 e 60 FPS (frames por segundo). Game Level ou nível, pode ser entendido como duas coisas diferentes: o nível em que o jogador está e o cenário do nível atual. Na verdade, Game Level é o conjunto de: cenário, missão e elementos que o jogador deve enfrentar em um jogo. Alguns jogos não possuem este conceito, por exemplo os “Never ending games” (jogos que não terminam), como o “Temple Run” (Imangi), por exemplo.
Jogabilidade Jogabilidade, ou “Gameplay”, é uma característica ligada à usabilidade do jogo, ou seja, o quão o jogo nos diverte. Eu diria que é o somatório das experiências do usuário durante o jogo, que envolve: facilidade de controle, boa visualização de informações, nível crescente e ajustado de desafios etc. Outros aspectos que influenciam a jogabilidade são: • Satisfação proporcionada ao jogador; • Facilidade de aprendizado do jogador; • Eficiência de envolvimento. O quão eficientemente o jogo envolve o jogador. Jogos eficientes envolvem o jogador rapidamente; • Motivação para evolução, ou se o jogo motiva o usuário a cumprir atividades; • Cativação, se o jogo faz com que o usuário volte a jogar novamente ou se interesse em adquirir novas fases, armas etc; Um dos fatores importantes na jogabilidade pode ser a socialização, ou a facilidade que o game dá para divulgar suas conquistas. Muita gente confunde “jogabilidade” com “facilidade de jogar”, que é um dos aspectos do conceito. Embora seja muito importante, a usabilidade (facilidade de jogar) sozinha não garante o sucesso de um game. Talvez seja melhor citar alguns exemplos de games com boa jogabilidade: • “Angry Birds”: simples, fácil e rápido de aprender; • “World of Goo”: fantástico! Embora seja um jogo de desafios, sua jogabilidade é ótima; • “Fruit Ninja”: igualmente simples e fácil, com alta eficiência de envolvimento.
14 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Fatores de sucesso de um Game Um game bem projetado envolve várias atividades separadas: • Game design: é o processo de criar o conteúdo, as regras, os cenários, os personagens e a Jogabilidade. É um trabalho que envolve várias especialidades, não apenas programação. Durante o Game design é muito produtivo criar protótipos do Game, para avaliar se o conjunto está agradando; • Mecânica do Jogo: estuda-se a interação do usuário com o Game, como serão os movimentos, controles e como serão implementadas as regras. A mecânica também se preocupa com o “desacoplamento” entre os níveis e o código-fonte, procurando parametrizar a seleção e montagem de níveis. Também pode envolver prototipação para estudo e seleção de alternativas, além de seleção de “engines”, modalidades e recursos para dar ao usuário a melhor experiência com o game; • Game construction: é a programação do jogo propriamente dita, que engloba a criação de artefatos (imagens, sons, música), níveis (cenários, dificuldade, etc) e código-fonte para a implementação da mecânica do jogo de acordo com o “Game design”; São aspectos diferentes entre si e que não devem ser tratados apenas como problemas de programação. Em equipes independentes (“indie gamers”) é comum termos esses conceitos misturados, porém, os games mais bem sucedidos são aqueles em que existem especialistas de várias dessas áreas envolvidos. Outro fator de sucesso, sem dúvida é o visual do Game, que deve ser atrativo e coerente com o Game design, ou com a história e ambientação. Um dos exemplos mais interessantes é o do “World of Goo” (2D Boy), que tem um visual meio dark e nojento, bem apropriado para as bolas de “Goo” do jogo (gosma ou coisa nojenta). Outros exemplos de visual interessante são: “Angry Birds” (Rovio), “Fruit Ninja” e “Jetpack joyride” (Halfbrick), “Where’s my water” (Disney) e vários outros. Quando falamos em jogos móveis (este trabalho é voltado para eles), a jogabilidade tem outros critérios de avaliação. Quando estamos em casa, com um Xbox / Kinect, da Microsoft, ou um PS3, temos vários recursos para controlar o Game, porém, quando estamos com um Smartphone (um iPhone ou um Android qualquer) ou mesmo um Tablet, os recursos são mais limitados. Embora contemos com acelerômetro e multitoque, não temos o mesmo controle que um Joystick ou um dispositivo ótico proporcionam. A tela é menor e, geralmente, não estamos em um ambiente ótimo para jogar (penumbra,
Capítulo 2 - Revisão de conceitos — 15
silêncio e uma tela de LED enorme). A jogabilidade para jogos móveis deve ser estudada levando-se em conta estas características. Um erro muito comum dos desenvolvedores de games móveis é criar as mesmas versões para smartphones e tablets. Não são a mesma coisa, embora, com o Samsung Galaxy S III e o iPhone 5, os smartphones estejam crescendo, ainda são bem diferentes de um tablet de 8 ou 10 polegadas. É necessário adaptar a jogabilidade ao tamanho do dispositivo. A originalidade também tem um papel importante no sucesso de um Game, especialmente se for do tipo “casual”. Games muito parecidos com outros, de maior sucesso, não são bem vistos e a tendência do público é considerar como uma imitação, mesmo que seja apenas uma coincidência. Quanto mais original seu game for, mais poderá chamar a atenção deste público. Neste livro, não vou falar sobre este assunto, mas a forma de “monetização” também pode ter um impacto positivo ou negativo no Game. Em meu livro anterior “Mobile Game Jam” (www.mobilegamejam.com), focamos muito nestes aspectos, porém, não podemos deixar de mencionar alguns problemas. Para começar, nem todos os usuários são fãs de jogos “Ads based” e podem se irritar com aquela faixa de tela utilizada para veicular anúncios. Eu tenho games publicados que são deste tipo, e a rentabilidade é muito baixa. Outra forma que deve ser evitada é cobrar licença de uso. Se você já cobra pelo uso sem deixar o usuário experimentar, é receita para o fracasso, a não ser que seu game seja realmente sensacional. O ideal é dar ao jogador uma maneira de experimentar o game antes de comprá-lo. Aí entra o processo de “in-app purchase” (compra por dentro da aplicação), e os “bens virtuais”. É uma maneira de fazer o usuário se div ertir e recuperar gradativamente o seu investimento.
Capítulo 3 Kit de ferramentas para criação de games Quando eu era garoto, passava aquele seriado antigo do Batman (DC Comics), no qual o Morcego tinha um “cinto de utilidades”, e eu achava aquele cinto sensacional. Com Games, não é muito diferente, pois necessitamos ter a mão (e saber usar) algumas ferramentas importantes, que nos pouparão muito tempo na criação de um Game. Dependendo do tipo de Game que você vai fazer, algumas ferramentas se tornam mais importantes e outras, desnecessárias, mas sempre existe um conjunto básico que você deve ter. Vamos mostrar algumas delas aqui.
Ferramentas gráficas Em Games, as imagens são fundamentais e nós já discutimos isso, agora, é o momento de entrarmos mais um pouco a fundo no assunto, focando nas principais ferramentas para criação de imagens.
Imagens Vamos começar a estudar imagens em seu formato virtual e, depois, vamos ver como ela é renderizada (apresentada) na tela. Toda imagem na memória é um conjunto de pontos. Estes pontos são armazenados na memória e, posteriormente, são exibidos para o usuário. Cada ponto tem as propriedades: coordenadas e cor. As coordenadas de um ponto indicam onde ele está em nosso “mapa” virtual (Bitmap – não confundir com o formato “BMP”). Dependendo do tipo de gráfico que estamos trabalhando, pode ser em um mapa tridimensional ou bidimensional. Como estamos falando de imagens, vamos supor que seja apenas bidimensional. Já a cor, normalmente é expressa em quantidade de vermelho (red), verde (green) e azul (blue), podendo também trazer a quantidade de transparência (Alfa). Vamos ver alguns exemplos simples de representações.
18 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Transparência e canal alpha As imagens podem trazer informações sobre trasnparência, conhecidas como “Canal Alpha”. Geralmente, um valor zero no canal alfa de uma imagem significa que ela é totalmente transparente, e um valor 100% significa que é totalmente opaca, mas isto varia de acordo com a ferramenta que utilizamos. Por exemplo, o LibreOffice Draw considera uma imagem com zero no canal alfa como sendo totalmente opaca, e um valor 100% como totalmente transparente.
Ilustração 6: Propriedades dos pontos gráficos no LibreOffice Draw
Como podemos ver na figura anterior (LibreOffice Draw), temos dois “pontos” (é claro que são imaginários) com suas características. Note como formamos a cor em formato RGBA – Red, Green, Blue e Alfa (http://www. w3schools.com/cssref/css_colors_legal.asp). No formato RGB – Red, Green e Blue (http://www.w3schools.com/cssref/css_colors_legal.asp), temos um indicador da quantidade ou intensidade de cada cor, representado por um valor entre 0 e 255 (1 byte), assim, podemos representar as cores: • Preto: RGB (0,0,0); • Branco: RGB (255,255,255); • Cinza: RGB (128,128,128);
Capítulo 3 - Kit de ferramentas para criação de games — 19
• Vermelho: RGB (255,0,0); • Verde: RGB (0,255,0); • Azul: RGB (0,0,255); • Amarelo: RGB (255,255,0); • Azul ciano (azul bebê): RGB (0,255,255); • Rosa bebê: RGB (250,200,200); Se quiser ver as combinações de RGB, pode usar um programa como o “LibreOffice Draw” (http://pt-br.libreoffice.org/), ou o Gimp (http://www. gimp.org/) e usar a ferramenta de Cores. Note que o diálogo de cores do LibreOffice Draw também tem uma aba de transparência, que permite ajustar a “opacidade” da cor. Como vimos na ilustração 6 (Propriedades dos pontos gráficos), além do formato RGB, também temos o canal “Alfa”, que regula a “opacidade” ou “transparência” da cor. Este formato é conhecido como “RGBA”. Existe alguma confusão quanto ao fato do canal representar o nível de opacidade ou de transparência. Uma boa referência, o W3Schools (http://www.w3schools.com/ cssref/css_colors_legal.asp), diz que o canal alfa representa o nível de opacidade da cor, variando entre 0 (totalmente transparente) e 1 (totalmente opaco). Já alguns softwares e literaturas tratam essa medida como percentual, variando entre 0% (totalmente transparente e 100% (totalmente opaco) (http:// en.wikipedia.org/wiki/RGBA_color_space). Vamos mostrar alguns exemplos de transparência. Temos que prestar atenção na forma como as ferramentas se referem ao canal “alfa”. O LibreOffice Draw encara como “transparência”, logo, 0% significa “opaco” e 100% significa “transparente”. Já, para o Gimp, uma camada 0% significa totalmente transparente, e 100% significa totalmente opaco.
Ilustração 7: Vários níveis de transparência
20 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Na imagem anterior, usamos o LibreOffice Draw para desenhar. Os quadrados, que possuem cor de fundo preta, estão com o canal alfa variando de 0 até 1, ou, na “linguagem” do LibreOffice, variando de 100% até 0% de transparência. Toda imagem é armazenada da mesma maneira. Porém, há alguns detalhes sobre imagens que precisamos conhecer.
Unidades de medida Quando falamos em imagens, é muito comum ouvirmos “pontos” e “pixels”, conceitos que muitas vezes nos confundem. Um “ponto” (point) é uma medida de tipografia e equivale a 1/72 de polegada. Um “pixel” é uma unidade gráfica de um dispositivo. É a menor unidade utilizável de um dispositivo gráfico, como uma tela de LCD. Temos que deixar claro que estamos estudando gráficos exibidos em telas, e não impressos, pois, caso contrário tudo mudaria. Resolução e densidade Há muita confusão sobre estas duas métricas. O senso comum diz que resolução é a quantidade de pixels, tanto na horizontal quanto na vertical, que um dispositivo pode exibir. Também serve para determinar o “tamanho” de uma imagem. Por exemplo, uma imagem de 5 Megapixels tem 5 milhões de pixels, o que não está relacionado ao tamanho físico. Uma tela WXGA, com 1280 x 800 pixels, tem 1,024 Megapixels. Já o tamanho que uma imagem ou tela terão depende muito da densidade, que é a quantidade de pixels por polegada (PPI) que a tela pode exibir (ou que a imagem espera ser reproduzida). Isto determina qual tamanho a imagem terá na tela real. Cada dispositivo tem sua densidade específica, por exemplo (fonte: gsmarena.com) : • Samsung Galaxy S III: 306 PPI; • Apple iPhone 5: 326 PPI; • Apple iPad 4: 264 PPI; • Apple iPad 2: 132 PPI; • Motorola Xoom 2 Media Edition: 184 PPI. Profundidade de cor A profundidade de cor (Color depth) é o número de bits necessários para representar cada cor de um pixel. Temos dois grandes sistemas para representar a cor: indexado e direto. O sistema indexado é utilizado quando temos
Capítulo 3 - Kit de ferramentas para criação de games — 21
baixa profundidade de cor, ou seja, temos poucos bits para representar cada cor. Então, é criada uma “palheta” (tabela) com as cores utilizadas e o código é, na verdade, o índice nesta tabela. Sistemas com 8 ou 12 bits por cor geralmente utilizam “palhetas” de cor. O sistema direto é quanto não usamos palheta, mas representamos diretamente o valor de cada cor. Podemos ter várias profundidades neste sistema, como 8 bits, 16 bits ou “true color”, que são 24 bits de cor (8 para cada cor – RGB). Com este sistema, podemos representar mais de 16 milhões de cores. É claro que o sistema direto requer mais memória (RAM ou arquivo) para representar as imagens, porém, fica mais fiel. Hoje em dia, o sistema indexado é utilizado para armazenamento de imagem, de modo a economizar espaço. Entre os tipos de arquivo que usam palheta temos: BMP, GIF e PNG.
Arquivos de imagem Existem vários padrões para armazenamento de imagem, cada um é melhor para determinada aplicação. Temos formatos que usam palheta, formatos que usam Color deph e formatos que podem utilizar os dois sistemas. Também podemos armazenar uma imagem de maneiras diferentes, como: “Raster” e “Vetorial”. Imagens em formato “Raster” são as mais comuns. São mais simples em termos de processamento, pois basicamente armazenam os pontos e suas cores. Só isso. É necessário apenas decodificar (ou descomprimir, caso necessário) o arquivo e acionar os pontos na tela com a cor necessária. Porém, o formato “Raster” tem a desvantagem de ser grande. É claro que podemos comprimir a imagem, perdendo ou não informação, mas, ainda assim, o arquivo é grande. Existem formatos de arquivo “Raster” que comprimem a imagem, com perda ou sem perda de informação. O JPEG, por exemplo, utiliza compressão com perda, que pode ser regulada através da qualidade do arquivo. Arquivos como PNG comprimem sem perda de informações, porém geram arquivos maiores. Já as imagens em formato vetorial não representam a imagem na forma de pixels, mas como vetores. Elas contêm as instruções para desenhar a imagem, permitindo mais suavidade na renderização. Os arquivos em formato vetorial são menores do que os em formato raster, porém, sua renderização exige mais recursos de processamento e pode gerar distorções quando utilizamos dispositivos diferentes.
22 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Os principais formatos de arquivos de imagem são: Formatos “Raster”: • GIF: usa compressão sem perda e pode representar imagens com palheta de 256 cores. Suporta animação e é mais indicado para imagens com grandes áreas de uma só cor, como: gráficos comerciais, desenhos simples etc; • BMP: é o formato nativo do Microsoft Windows. Não usa compressão e pode representar “true color” (24 bits). Os arquivos BMP são grandes, em comparação com outros formatos; • PNG: suporta “true color” com canal alfa (transparência). É mais indicado para imagens com grandes áreas de cor única. Também suporta imagens em sistema direto, sem palheta; • JPEG: comprime imagens de maneira extraordinária, podendo reduzir absurdamente o tamanho do arquivo. Porém, usa compressão com perda de informação. Uma vez salvo, não é possível restaurar a imagem original. É mais indicado para arquivos de fotografias; Formatos vetoriais: • SVG: “Scalable Vector Graphics”, um formato padrão, criado pelo W3C, e utilizado no HTML 5; • CGM (Computer Graphics Metafile), CDR (Corel Draw), WMF (Windows Metafile Format);
Ferramenta para “Sketch” É muito importante criar “Sketches” ou rascunhos das imagens de seus Games. Nada impede que você use papel e lápis para isto, mas um “Sketch” digital tem a vantagem de poder ser utilizado diretamente. Eu me lembro quando criava Games para Windows e desenhava a imagem em papel para depois passar por um “Scanner”. O ideal é ter algum dispositivo para digitalização de desenho livre, como uma mesa digitalizadora, que é um “tablet” (não confundir com Tablet Mobile), que tem uma caneta especial e permite capturar seu desenho manual. Outra opção é usar algum aplicativo de desenho em um Tablet mobile, como o iPad (ou algum dispositivo Android). Eu costumo usar o “Sketchbook Pro for iPad”, da AutoDesk. É um programa barato e muito fácil de usar e que me permite criar imagens rapidamente, como a da figura a seguir.
Capítulo 3 - Kit de ferramentas para criação de games — 23
Ilustração 8: Rascunho de personagem de Game
Utilizando uma caneta para iPad, consigo desenhar rapidamente e até colorir a imagem, sem grande sacrifício, pois posso apagar partes e corrigir rapidamente.
Ferramentas para desenho Raster Na minha opinião, a melhor ferramenta para edição de imagens raster é o PhotoShop, da Adobe. Não há dúvida alguma sobre isso. Porém, ele tem um custo de licença relativamente alto, com relação a outras alternativas. Na minha opinião, se você tiver condição, compre logo a licença da Adobe Creative Suite 6, que já vem com tudo o que você pode necessitar para criar gráficos. Porém, se você é um “Indie Gamer”, provavelmente não estará tão motivado assim para investir uma alta soma, especialmente se estiver começando. Mas lembre-se: o investimento se pagará com a qualidade e facilidade de uso dos produtos da Adobe. A opção mais popular de editores “raster” gratuitos é o Gimp (http://www. gimp.org/) - GNU Image Manipulation Program. É um editor poderoso, com interface simples, porém dotado de muitos efeitos interessantes. Há versões para MS Windows, Mac OS X e Linux. Entre outras coisas, o Gimp permite: • Trabalhar com transparências; • Trabalhar com vários formatos diferentes de imagem, inclusive SVG;
24 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
• Criar imagens com múltiplas camadas, que podem ser superpostas; • Criar animações em GIF; • Selecionar partes da imagem de várias formas: retangular, elíptica, livre, por cor etc; • Usar várias ferramentas nativas de transformação, cor e pintura; • Usar vários filtros de efeitos, como: entalhar, esculpir, realçar, pintura a óleo, clarões etc;
Ilustração 9: Exemplo de uso de filtro de clarões do Gimp
Uma opção profissional de custo relativamente mais baixo é o Corel Painter (http://www.corel.com/corel/product/index.jsp?pid=prod4030123&cid=catalog3590 073&segid=2100019&storeKey=br&languageCode=pt). Ele permite baixar uma versão de avaliação (30 dias) que dá uma boa ideia dos seus recursos, porém só está disponível para MS Windows e Apple Mac OS X. É melhor do que o Gimp em alguns aspectos, como os recursos de pintura digital.
Ferramentas para desenho vetorial Você pode trabalhar com imagens vetoriais em seu game. Em meu livro anterior “Mobile Game Jam” (www.mobilegamejam.com) nós utilizamos imagens vetoriais, sendo desenhadas em um Canvas. Porém, isto pode demandar recursos de processador, caso seu game seja muito complexo. Outra maneira de trabalhar com imagens vetoriais é criá-las em um editor de imagens vetoriais e, posteriormente, converter os arquivos em algum formato raster. O Gimp faz isto com perfeição.
Capítulo 3 - Kit de ferramentas para criação de games — 25
Comparação entre renderização raster e vetorial A renderização raster é muito rápida e o trabalho pode ser dividido com a GPU (Graphics Processing Unit), evitando “lags” no game. Porém, é mais sujeita às diferenças de densidade e resolução entre os equipamentos, exigindo versões diferentes ou então operações de “resizing”, que podem resultar em arquivos serrilhados (pixelados, uma consequência do processo de redução / ampliação). Já a renderização vetorial é uma operação de desenho completa e, geralmente, é executada na CPU. Existem alguns artigos interessantes sobre técnicas para utilizara a GPU na renderização de desenhos vetoriais, entre eles este, da NVidia: http://http.developer.nvidia.com/GPUGems3/gpugems3_ch25. html, porém, não é a forma comum de se trabalhar. A vantagem da renderização vetorial é a suavidade do desenho, que pode ter sua escala facilmente aumentada ou diminuída. Além disto, os arquivos vetoriais são menores e não é necessária mais de uma versão de cada imagem. De qualquer forma, eu sempre crio a imagem dos meus games em formato raster (usando o “Sketchbook pro”) e depois converto para formato vetorial. No final, eu converto as imagens vetoriais finalizadas em formato raster, com um arquivo para cada tamanho (resolução / densidade) de dispositivo.
Ilustração 10: Um ensaio de personagem
Na figura anterior, vemos dois ensaios de uma personagem de game que eu estou criando. O da esquerda (raster) foi criado rapidamente no “Sketchbook pro”. Note como ainda mantive as linhas de construção que usei. A imagem da direita é feita em formato vetorial (no aplicativo “iDesign”, para iPad). Eu tenho a tendência de criar personagens em estilo “mangá”, porém, resolvi dar
26 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
uma “ocidentalizada” no rosto (arredondando e diminuindo a inclinação dos olhos). Mas ficou do jeito que eu queria: uma personagem meio rebelde e misteriosa. Eu não vou trabalhar com a imagem vetorial, mas vou utilizá-la para criar imagens raster. O desenho vetorial me ajuda a criar imagens mais perfeitas, já que eu não tenho muito talento para desenho manual. A ferramenta que eu gosto de usar para criar desenhos vetoriais é o “iDesign” para iPad, da Touchware (https://itunes.apple.com/us/app/idesign/ id342790226?mt=8). Ele é barato (cerca de US$ 5,00), possui os recursos básicos de edição vetorial, como: curvas de Bèzier, auto fechamento, pintura, rotação, combinação com imagens de fundo e até gradientes. Outra boa ferramenta, na minha opinião é o Fireworks, da Adobe (http:// www.adobe.com/br/products/fireworks.html), uma ferramenta voltada para web designers, que permite trabalhar imagens vetoriais e raster também. Embora seu uso seja mais voltado para criação de Web designs (sua integração com CSS é fantástica), também permite gerar temas para JQuery Mobile e trabalhar imagens vetoriais. Seu preço é um pouco alto, assim como toda a suite da Adobe, mas acredite: o investimento se pagará em produtividade! É claro que não podemos sequer falar em gráficos vetoriais sem mencionar o Corel Draw (http://www.corel.com/corel/product/index. jsp?pid=prod4260069), cujo preço da licença é mais competitivo e os recursos são fantásticos. Como eu mencionei anteriormente, eu uso o iDesign, da TouchWare (), na versão iPad. Embora tenha menos recursos que os produtos mencionados, tem baixo custo e funciona muito bem no iPad. Sua licença custa cerca de US$ 5,00, e vem com um tutorial muito fácil de entender. Agora, se você quer uma opção gratuita, eu recomendaria o Inkscape (www.inkscape.org), que é Open Source e gratuito. Ele possui versões para MS Windows, Apple Mac OSX e Linux. É muito fácil de usar e permite gerar a maioria das imagens que você necessita. Suas características importantes são: • Trabalha com camadas de desenhos; • Gera arquivos raster a partir dos desenhos vetoriais; • Consegue importar vários formatos de imagem, tanto vetoriais como raster; • Possui vários filtros gráficos sensacionais.
Capítulo 3 - Kit de ferramentas para criação de games — 27
Ilustração 11: O ensaio de personagem no Inkscape
O Inkscape pode ser gratuito, mas é cheio de surpresas. Assim como o Gimp, ele também tem filtros sensacionais, que permitem dar um efeito profissional em seus trabalhos. Para dar um exemplo, eu transformei a imagem da minha personagem em um “android”, com efeito “Glowing Metal”.
Ilustração 12: Imagem “robotizada”
28 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Game engines Um Game engine é uma ferramenta para facilitar o desenvolvimento de games. Eles são compostos por editores, bibliotecas e interface de programação, que podemos utilizar para criar nossos próprios games. Existem game engines para várias plataformas, seja 2D ou 3D, com níveis diferentes de facilidade de uso. Adotar um Game engine pode aumentar sua produtividade, permitindo que você se concentre mais no Game design (incluindo a jogabilidade), ao invés de se preocupar com detalhes de animação, game loop etc.
HTML 5 + Javascript Existem vários Game engines para HTML 5 + Javascript. Já que a Adobe discontinuou o desenvolvimento do Flash player para dispositivos móveis, a galera que fazia jogos em Flash está migrando para HTML 5 e Javascript. Em meu último livro, “Mobile Game Jam”, eu mostrei como desenvolver games móveis usando HTML 5 e Javascript, e mostrei um engine multiplataforma: o PhoneGap (Cordova). Mas não mostrei nenhum Game engine desta plataforma. O ImpactJS (impactjs.com) é uma boa opção. Ele permite criar games em HTML 5 + Javascript para Desktops e dispositivos móveis. Vem com um editor de níveis (o Weltmeister), que facilita muito a criação de cenários para diferentes níveis do jogo. Ele vem com ferramentas de depuração e empacotadores, que permitem colocar sua aplicação diretamente na AppStore iOS. Seu custo é relativamente barato (US$ 99,00). Existem vários outros game engines para HTML 5 + Javascript, porém o ImpactJS, na minha opinião, é o mais profissional.
Nativos Um Game engine nativo é aquele que possui bibliotecas nativas e executa fora do ambiente do Browser. Geralmente, apresentam melhor performance do que os Game engines baseados em HTML 5 + Javascript. O Cocos2D for iPhone (http://www.cocos2d-iphone.org/) é um port do Game engine original “Cocos2D”, para Python. É bastante popular, com vários games famosos no mercado. Ele vem com um ambiente de desenvolvimento (CocosBuilder), a biblioteca do Game engine em si (cocos2D), e um engine de física (Chipmunk). Existem versões para HTML 5 (http://cocos2d-x.
Capítulo 3 - Kit de ferramentas para criação de games — 29
googlecode.com/files/Cocos2d-html5-v2.1.zip) e Android (http://code.google. com/p/cocos2d-android/). É gratuito. O AndEngine (http://www.andengine.org/blog/) é um game engine para Android, similar ao Cocos2D. Também é gratuito e open source. Para quem quer um engine mais profissional, tem o Antiryad Gx (http:// www.arkham-development.com/antiryadgx.htm), que permite criar games 2D ou 3D para várias plataformas, entre elas: iOS e Android. Ele possui vários tipos de licenças, desde gratuita até “enterprise” (900 euros). Porém, se você quiser realmente “detonar” com um Game 3D animal, use o UDK – Unreal Development Kit (www.udk.com) para iOS, Android, PS3, Xbox, Windows, Mac OSX etc. Com o UDK, você pode desenvolver seu game sem custo e, quando estiver pronto para publicar, pagará uma taxa de US$ 99,00 (única). Porém, há mais cobranças se você alcançar US$ 50.000,00 em vendas. Finalmente, temos o Unity (http://unity3d.com/), que é um engine para games 3D muito interessante. A versão básica é gratuita e permite criar games para MS Windows e Apple Mac OSX, e você pode criar games comerciais com ela. Se quiser criar games para dispositivos móveis, você deve adquirir os “add-ons” específicos, como: Unit iOS (US$ 400,00) e Unit Android (US$ 400,00). Também existem as licenças “pro” para estes ambientes. O Unity é sensacional e vem com um editor fantástico.
Ilustração 13: Jogo que estou criando com o Unity
Se você vai criar um game 3D, é melhor considerar uma ferramenta de desenho apropriada. Eu recomendo o AutoDesk Maya (http://usa.autodesk.com/ maya/), apropriado para criação de personagens 3D com animação. Agora, se
30 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
preferir um software gratuito, o Blender (http://www.blender.org/) é uma excelente opção. E os modelos 3D criados com o Blender podem ser importados em game engines 3D, como o Unity.
Usar a plataforma nativa Embora um Game Engine possa acelerar o desenvolvimento do seu game, ele também tem algumas desvantagens, entre elas: • Ficar “preso” aos recursos disponíveis no Engine; • Complexidade de aprender a API; • Preço da licença; • Dependência de suporte (ou comunidade) para Bugs; Além destas possíveis desvantagens, é necessário considerar que as APIs das plataformas nativas, tanto do Android, como do iOS, permitem criar games com recursos avançados, como o OpenGL ES, por exemplo. Mesmo que você queira utilizar um Game Engine, eu considero fundamental que você saiba o que está acontecendo e, para isto, nada melhor do que criar um game “na mão”, sem engine algum. Depois, se considerar relevante para o seu projeto, poderá escolher um Game Engine apropriado, porém saberá avaliar e usar melhor as funcionalidades, pois já sabe os detalhes de programação de games.
Ambientes multiplataforma Fora os Game engines, existem alguns ambientes multiplataforma para criação de aplicações móveis, alguns deles com recursos para Games. Para começar, vou vender o meu “peixe”. No nosso portal The Code Bakers (www. thecodebakers.org), criamos um framework de aplicações móveis multiplataforma, o AAMO (www.aamoframework.org). Ele é baseado em Lua e pode gerar aplicações em iOS e Android. Futuramente, haverá versões para Desktop e também um game engine embutido: o AAMO Xtreme. O AAMO é totalmente gratuito e Open Source, mas ainda não contempla os recursos para criação de Games. Outro ambiente multiplataforma interessante é o CoronaSDK (http://www. coronalabs.com/products/corona-sdk/). Ele também é baseado na linguagem Lua e possui recursos muito interessantes para criação de games. Ele foi escolhido pela EA (famosa por vários games) para criar seu jogo móvel: World Smack (http://www.coronalabs.com/blog/2012/11/08/corona-spotlight-ea-chooses-corona-for-word-smack/). O corona não é gratuito, mas tem licença de baixo custo, só necessária quando você for publicar o game.
Capítulo 3 - Kit de ferramentas para criação de games — 31
Prototipadores Um grande recurso, geralmente ignorado pelos desenvolvedores de game, é a prototipação. Este recurso nos permite capturar, de maneira simples e clara, os requisitos de uma aplicação. Como games possuem requisitos muito subjetivos (jogabilidade é um deles), a prototipação pode ser um excelente instrumento para acelerar o desenvolvimento, evitando retrabalho. Seja utilizando um Game engine ou não, o comprometimento do projeto de um game cresce rápida e exponencialmente. Em poucas semanas, já temos tanto código escrito que criamos um forte comprometimento com ele, deixando de ver coisas óbvias. Com um protótipo isto é evitado, pois podemos jogar tudo fora e começar novamente. Felizmente, existem alguns softwares excelentes para prototipação de games e aplicações em geral. Eu uso dois deles, que considero excelentes. Para começar, gostaria de falar do Codea (http://twolivesleft.com/Codea/), uma ferramenta que roda diretamente (e apenas) no iPad. Com ela, podemos programar diretamente no iPad, sem necessidade de um laptop ou desktop. Infelizmente, só existe versão para iPad. Podemos até baixar o Codea Runtime (https://github.com/TwoLivesLeft/Codea-Runtime) e distribuir a aplicação na AppStore. A licença do Codea custa US$ 9,99 e acredite: ela se paga rapidamente! Vou mostrar o protótipo de um game que foi feito originalmente no Codea em menos de 4 horas! Eu aprendi a usar o Codea, aprendi a linguagem Lua e criei o Game enquanto estava hospitalizado, aguardando uma cirurgia renal. Quando fui para a sala de cirurgia, o Game estava pronto. É claro que era um protótipo, mas estava funcionando completamente. Eu sou fã do Codea, porém, fiquei completamente atônito e pasmo quando conheci a oitava maravilha do mundo: O Processing (http://processing.org/)! É aquele tipo de coisa bombástica que nos faz gritar: “Para tudo!” O Processing é um ambiente de simulação e prototipação, que já vem com tudo em cima para criar Games. Ele usa uma linguagem “tipo” java, mas também permite exportar seu projeto para HTML 5 + Javascript e até mesmo Android, ou seja: lava, passa, cozinha e, ainda por cima: é gratuito!
Bibliotecas auxiliares Como já discutimos, um Game é um projeto muito complexo, com várias macrofunções diferentes. Certamente, podemos utilizar Game engines para nos auxiliar, porém, ainda existem alguns motivos importantes para estudarmos as bibliotecas em separado:
32 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
1. Custo. Se criarmos diretamente o game, não necessitaremos adquirir licenças e, como já discuti, alguns Game engines possuem requisitos de licença meio complexos; 2. Domínio. Se você souber o que está “por baixo do capô”, vai saber tirar maior proveito dos componentes para seu Game; Uma ferramenta importante é um engine gráfico que permita utilizar os recursos de aceleração de sua placa de vídeo (GPU – Graphics Processing Unit) e esta ferramenta é a biblioteca OpenGL ES (http://www.khronos.org/opengles/), que é uma versão da OpenGL (http://www.opengl.org/) para dispositivos móveis. A principal vantagem é renderizar imagens mais rapidamente, utilizando multiprocessamento compartilhado entre CPU e GPU, tanto para gráficos 2D como para 3D. Tanto no iOS como no Android, existem recursos para facilitar o uso do OpenGL ES na criação de games. E, finalmente, temos a biblioteca Box2D (http://box2d.org/), que é um “physics engine”, ou seja, uma biblioteca que fornece funções físicas, como: movimento, forças e colisões para nossos games parecerem mais realistas. Para ter uma ideia de quanto a Box2D é importante, o game “Angry birds” a usa como seu “physics engine”.
Capítulo 4 Prototipação do game Bem, vamos começar realmente o nosso game. Imaginemos uma ideia simples e vamos tentar criar um protótipo com ela, de modo a avaliar se está boa para virar um Game. Tudo começa com uma ideia. Que tal criarmos um “shooting” game? Afinal de contas, “shooting” games são fáceis de criar e, geralmente agradam a todos. Podemos começar com um rascunho (“Sketch”) do game. Eu pensei em um game de escapar de asteroides. Você está em uma nave e tem que manobrar em um campo de asteroides, evitando colidir com eles. Você ganha pontos de acordo com a distância percorrida, em anos-luz. Uma dica: se você começar a complicar, pensando coisas do tipo: “já tem muita coisa assim, preciso criar algo diferente...” seu projeto não vai andar. Você vai criar um protótipo do jogo, logo, não é o momento de pensar nisso. Depois, você pode fazer as otimizações que desejar. Um rascunho é tudo o que precisamos Eu peguei meu iPad e, usando o “Sketchbook”, criei vários desenhos. Eu fui desenhando o que me vinha à mente, sem me preocupar muito com estética ou funcionalidade. O desenho que mais gostei foi o da próxima figura. A nave se move para cima e para baixo, e os asteroides vêm em sua direção, pois ela está se movendo para a frente. Existem asteroides “bons” (os brilhantes), que repõem a energia da nave, e os asteroides ruins (opacos), que podem destruir a nave. Caso ela colida com um asteroide ruim, sua energia é drenada, pois ela usa os “escudos” para impedir sua colisão.
34 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 14: Rascunho do jogo
Se o seu jogo tiver mais telas ou níveis, você pode ir criando rascunhos para cada um deles. O importante é colocar a idea no papel (digital), antes que você se esqueça.
Usando o Codea O Codea é um ambiente de programação sensacional. É simples e prático, permitindo criar rapidamente jogos e simulações gráficas. Ele é uma aplicação para iPad, que está disponível na AppStore por cerca de US$ 10,00.
Ilustração 15: A tela inicial do Codea
Capítulo 4 - Prototipação do game — 35
Os pontos positivos do Codea são: 1. Tem recursos que facilitam o desenvolvimento rápido de games; 2. Já vem integrado com o engine de física Box2D; 3. Usa a linguagem Lua (www.lua.org), que é simples e pouco verbosa; 4. Vem com Spritepacks (conjuntos de imagens) e outros recursos para criação de games. Porém, o Codea carece desesperadamente de documentação decente. Não parece um produto comercial e, certamente, não tem o suporte necessário, em caso de problemas. O site da empresa, “Twolivesleft.com”, é muito minimalista e não tem uma documentação formal. Existem vários tutoriais (alguns criados por terceiros) que explicam rapidamente como ele funciona. É claro que podemos criar Games com o Codea e distribuir na AppStore, pois ele tem um “Codea Runtime” (https://github.com/TwoLivesLeft/Codea-Runtime) que permite fazer isto. Porém, quando se trata de criar um produto comercial, eu sou muito conservador, pois, em caso de problemas, o meu cliente vai reclamar comigo e eu, como desenvolvedor do Game, não terei a quem recorrer. É por isso que eu não uso o Codea para desenvolvimento, mas apenas para prototipação. No Codea, podemos criar código-fonte para responder a eventos e para criar classes, que serão utilizadas no Game. A linguagem é a Lua (www.lua. org), criada pela PUC-RJ. Lua é uma linguagem simples, intuitiva e pouco verbosa, cujo aprendizado é extremamente simples. Nós iremos ensinando Lua conforme o protótipo for se desenvolvendo. Aliás, foi assim que eu aprendi a usar Lua. Só para começar, Lua é “canse sensitive” e não precisa de ponto e vírgula no final da linha. Os comentários são iniciados por “--”.
Um tutorial rápido em Codea Vamos fazer uma “porcaria” em Codea para você “pegar o jeito”. Comece iniciando um novo projeto, na tela do Codea (Ahn, galera: só funciona no iPad, ok? Se você não tem um iPad, leia a prototipação no “Processing”). Escreva um nome qualquer para o projeto, por exemplo: “bola”. Ele vai criar todo o código do projeto, que apenas escreve “Hello world” na saída.
36 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 16: O código gerado pelo Codea
Ele gerou um código-fonte muito simples, como podemos ver na figura. O arquivo “Main” é o arquivo principal de sua aplicação Codea, ele contém algumas funções de “Callback”, ou seja, são invocadas pelo engine do Codea em determinadas situações. A primeira função é a “setup”, que é chamada na inicialização. Neste caso, estamos usando a função “print” para gerar uma mensagem na saída da execução. A outra, é a função “draw”, invocada a cada novo frame gerado. Neste caso, ela apenas limpa a tela, com a função “background (
, , )”, e ajusta a grossura da “caneta” para desenhos em 5 pixels, com a função “strokeWidth(5)”. Se você quiser ver uma referência rápida (algo semelhante a um “Help”), toque no símbolo do Codea na tela principal (É um quadrado verde com dois “C”, um grande e um pequeno). Como eu rodo o programa? Vamos ver uma referência rápida dos comandos do editor: • Seta para a esquerda, no canto superior esquerdo: volta ao início; • Botão “+”, no canto superior direito: cria uma nova classe ou um arquivo vazio; • Tabs na parte superior da tela: alternam entre os arquivos; • “X” no canto inferior esquerdo: fecha o arquivo; • Botão play, no canto inferior direito: executa o programa;
Capítulo 4 - Prototipação do game — 37
Clique no botão “play”, no canto inferior direito da tela do editor e você irá para o ambiente de execução.
Ilustração 17: O ambiente de execução
O ambiente de execução é bem simples. Ele tem um painel de propriedades, um de output (para saída de comandos “print”), e uma barra de controle. A barra de controle está sempre no canto inferior esquerdo, e tem alguns botões: • Seta para a esquerda: termina o programa; • Pausa / Play: para ou continua a execução do programa; • Seta curva: reseta o programa, executando do início; • Máquina fotográfica: tira uma foto instantânea do programa; • Filmadora: filma a execução do programa; Agora, vamos fazer uma coisa mais legal. Vamos criar um foguete que se move. Altere o arquivo “Main” para acrescentar as linhas em negrito abaixo: -- bola -- Use this function to perform your initial setup function setup() print(“Hello World!”) x = 0 y = HEIGHT / 2 supportedOrientations(PORTRAIT_ANY) displayMode(FULLSCREEN) end
38 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS -- This function gets called once every frame function draw() -- This sets a dark background color background(40, 40, 50) -- This sets the line thickness strokeWidth(5)
end
-- Do your drawing here x = x + 1 if x > WIDTH then x = 0 end sprite(“SpaceCute:Rocketship”,x,y)
O resultado da execução é um foguete que sai do canto esquerdo da tela e vai até o canto direito, recomeçando quando atinge o fim.
Ilustração 18: O foguete se move pela tela
Vamos ver o que mudamos. Para começar, criamos duas variáveis, “x” para posição horizontal e “y” para vertical. Note que o valor de “y” é a metade da tela (HEIGHT / 2) fixamos a orientação em “portrait”, pois queremos que
Capítulo 4 - Prototipação do game — 39
o iPad seja segurado “em pé”. Faz sentido, pois os asteroides ficarão mais próximos. Depois, retiramos os painéis que aparecem do lado esquerdo com a função “displayMode(FULLSCREEN)”. Sempre que for necessário criar um novo frame, a função “draw” será invocada. E, neste momento, ela vai apagar a tela e desenhar um “sprite” (uma imagem) de uma das coleções de sprites que já vem com o Codea. É um pequeno foguete com um tripulante. Ele vai desenhar sempre na posição indicada pelas variáveis “x” e “y”. O Codea usa a orientação cartesiana correta, com o ponto inicial (0,0) no canto inferior esquerdo. A cada chamada da função “draw”, vamos incrementando o valor de “x”. Quando ele se torna maior que a largura da tela (WIDTH), nós voltamos ao valor zero.
Criando o protótipo do “AsteroidNuts” no Codea Agora, que já entendemos um pouco do Codea, vamos tentar criar nosso protótipo. Para começar, temos dois tipos de Game Objects: Nave e Asteroide. A nave é um Player Object, ou seja, controlado pelo usuário, e o asteroide é um NPC. Todo o código-fonte pode ser baixado, logo, não é necessário digitar os comandos. Veja na “Introdução”. Tem um arquivo “Asteroid.txt” (“..\Codigo\ AsteroidNuts_Codea\Asteroid.txt”) com a classe toda. Não importa se temos dois tipos de asteroides (bom e ruim), pois são apenas variações de propriedades de um asteroide. Nossa classe tem que armazenar os atributos de posição do Asteroide, além do sei tipo (“bom” ou “ruim”). O Codea usa a API Nspire (http://www.inspired-lua.org/2011/05/5-object-classes/), que facilita a criação de classes. Para criar uma nova classe, abra o editor do projeto e clique no botão “+”, que fica no canto superior direito. Escolha “Create new class” e dê o nome “Asteroid”. Você vai notar que já tem alguns comandos: Asteroid = class() function Asteroid:init(x) -- you can accept and set parameters here self.x = x end
40 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS function Asteroid:draw() -- Codea does not automatically call this method end function Asteroid:touched(touch) -- Codea does not automatically call this method end
A linguagem Lua não suporta a criação de classes diretamente, porém como sendo uma linguagem baseada em protótipos, permite modificar o comportamento e característica de objetos dinamicamente, imitando o conceito de classes. A API Nspire tem a função “class()” que facilita a criação de classes. Neste caso, criamos um objeto “Asteroid” e acrescentamos a ele uma propriedade (“x”) e três métodos (“init”, “draw” e “touch”). O método “init” será invocado sempre que quisermos pegar um novo objeto da nossa classe, por exemplo: ast1 = Asteroid(10)
Os métodos “draw” e “touched” são dicas que deveríamos implementar estes métodos e invocá-los, quando necessário. O Codea inseriu comentários indicando que estes dois métodos não serão invocados automaticamente. Para começar, vamos trabalhar nosso construtor (o método “init”). Primeiramente, temos que pensar em como iniciar o Asteroide. Cada Asteroide deverá surgir no canto direito da tela, em uma altura variável. Haverá uma faixa de valores no eixo das ordenadas (Y), onde os asteroides poderão surgir. Eu não quero usar a tela toda, pelo menos neste protótipo. Então, faz sentido passarmos em passar os limites para o construtor da classe, de modo que este possa gerar um asteroide dentro da faixa esperada. function Asteroid:init(upper,lower) -- you can accept and set parameters here local tipo =math.random(1,100) self.position = vec2(100,50) self.dimension = vec2(50,50) self.position.x = WIDTH - 100 self.position.y = math.random(upper,lower) if tipo > 80 then self.type = 2 else self.type = 1 end end
Capítulo 4 - Prototipação do game — 41
O prefixo “Asteroid:” indica que este é um método a ser atribuído aos objetos do tipo “Asteroid”, e o sufixo “init” é o nome do método. O prefixo “self.”, em alguns comandos de atribuição, se refere ao objeto da classe “Asteroid”, logo, “self.type = 2” significa que estamos atribuindo “2” à propriedade “type”, do objeto. Se ela não existir, será criada automaticamente. Lua é uma linguagem de tipagem dinâmica, ou seja, cada variável tem seu tipo atribuído dinamicamente (e pode mudar). Passamos dois parâmetros para o construtor: “upper” – limite superior no eixo das ordenadas, e “lower” – limite inferior. Nós criamos três propriedades no construtor: • “position”: do tipo “vec2” (do Codea), que retorna um vetor com coordenadas bidimensionais (“x” e “y”). Armazena a posição atual do asteroide; • “dimension”: igualmente vec2, armazena a largura e altura da caixa que contém a figura do asteroide; • “type”: do tipo “number” (Lua), que armazena o tipo do asteroide, se ele é “bom” (type = 2) ou “ruim” (type = 1). É claro que isto poderia ser resolvido melhor por hierarquia ou composição, mas é um protótipo, logo, é descartável; Nossa estratégia para decidir se vamos criar um asteroide “bom” ou “ruim” é apenas aleatória. Poderíamos levar alguns fatores em consideração, como o nível de energia do jogador e a quantidade de acertos que ele tem, mas, para efeito de protótipo, vamos deixar assim. Geramos um número aleatório entre 1 e 100 (math.random(1,100)) se ele for maior que 80, nós o transformamos em “bom”. Inicialmente, calculamos a posição horizontal (abscissas) em WIDTH – 100. Depois vou explicar o motivo, mas está relacionado com o tamanho do asteroide, que é 50. E calculamos a posição vertical (ordenadas) com um número aleatório entre o limite inferior e o superior (math.random(upper,lower)). Agora, demos que desenhar um asteroide. O melhor local para fazer isto é no método “draw”. Nós invocaremos o método “draw” sempre que desejarmos redesenhar um objeto Asteroid específico. function Asteroid:draw() -- Codea does not automatically call this method spriteMode(CORNERS) if self.type == 2 then sprite(“Tyrian Remastered:Energy Orb 1”, self.position.x, self.position.y, self.position.x+self.dimension.x,
42 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS self.position.y+self.dimension.y)
else
end
end
sprite(“Tyrian Remastered:Eggstroid”, self.position.x, self.position.y, self.position.x+self.dimension.x, self.position.y+self.dimension.y)
A primeira coisa que fizemos foi mudar o modo de desenhar um “sprite”, que é uma imagem carregada na tela. Por default, o Codea sempre considera a posição de um sprite (coordenadas “x” e “y”) como o centro da imagem. Eu prefiro trabalhar com os cantos e especificar o tamanho da imagem, então eu mudei o modo de desenho para CORNERS (spriteMode(CORNERS)), que significa: “x” e “y” serão o canto inferior esquerdo da “caixa” que contém o sprite, e os parâmetros “w” e “h” serão, respectivamente, as coordenadas do canto superior direito da mesma. Se for do tipo “2”, é um asteroide “bom”, logo, eu uso um sprite da biblioteca do Codea, que representa uma esfera brilhante. Eu uso a função “sprite” para desenhar a figura do asteroide na tela, na posição especificada (propriedade “position”) e do tamanho especificado (propriedade “dimension”). O primeiro parâmetro é o nome da figura, o segundo e o terceiro, as coordenadas do canto inferior esquerdo, e o quarto e o quinto, as coordenadas do canto superior direito. Finalmente, temos dois métodos “finalx” e “finaly”, que retornam as coordenadas do canto superior direito do asteroide, para facilitar seu uso. Agora, precisamos representar a nave (arquivo “ship.txt”). Vamos criar uma classe da mesma maneira. Em seu método “init”, nós criamos as mesmas propriedades do asteroide, exceto o “type”. Mas colocamos o “lower” e o “upper” como propriedades da nave. Veja o método “init”: function ship:init() -- you can accept and set parameters here self.position = vec2(100,80) self.dimension = vec2(100,50) self.position.y = (HEIGHT - self.dimension.y)/2 self.upper = self.position.y - 4* self.dimension.y self.lower = self.position.y + 5 * self.dimension.y end
Capítulo 4 - Prototipação do game — 43
O método “draw” é mais simples:
function ship:draw() -- Codea does not automatically call this spriteMode(CORNERS) sprite(“SpaceCute:Rocketship”,self.position.x, self.position.y, self.position.x+self.dimension.x, self.position.y+self.dimension.y) end
Desenhamos da mesma maneira que o asteroide. E a nave também tem os métodos “finalx” e “finaly”. Na verdade, ambos (Asteroid e ship) poderiam ser derivadas de uma classe ancestral comum “GameObject”, mas eu não quis complicar as coisas, pois é um protótipo. Um método “callback” importante é o “touched”, que será invocado quando o usuário tocar na tela. function ship:touched(touch) -- Codea does not automatically call this method if touch.y >= self.upper and touch.y <= self.lower then self.position.y = touch.y end end
Eu simplesmente pego a altura do toque do jogador e posiciono a nave nela. Note que não é necessariamente o toque na nave, mas na tela toda. Eu poderia sofisticar este controle, mas, como é um protótipo, eu preferi deixar tudo mais simples. Na verdade, quem invoca este método é o meu arquivo “Main”, já que ele não será invocado automaticamente pelo Codea (veja o comentário no início do método “touched”. Agora, chegou o momento de criar a mecânica do jogo. Eu poderia ter usado os recursos de física cinética do Codea (fornecidos pela biblioteca Box2D), com o objeto “body”. Assim, toda a parte de movimento e colisão seria automática. Porém, considerei que o protótipo ficaria complexo demais e, na verdade o jogo é muito simples na parte de física. Então, eu mesmo criei a física necessária para movimentar e verificar colisões. No arquivo “Main”, eu crio algumas variáveis globais para controle do jogo, as quais nem vou mencionar diretamente, exceto duas: • “asters = {}”; • “nave = nil”;
A variável “asters” é do tipo “table”, que é um array dinâmico e associativo em Lua. E a variável “nave” é um objeto, inicializado com valor nulo (“nil”). A table “asters” vai armazenar os asteroides ativos em determinado momento (aqueles que não chegaram ao fim do caminho: o canto esquerdo da tela), e a variável “nave” vai representar o objeto “ship”.
44 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
A função “setup” será invocada automaticamente pelo Codea, e inicializa as variáveis do game. function setup() displayMode(FULLSCREEN) upperlimit = (HEIGHT - 50) / 2 - 200 lowerlimit = (HEIGHT - 50) / 2 + 250 nave = ship() lightyears=0 energy=200 contador=0 fimjogo=0 posdec=1 limrandom=60 inirandom=1 gatilho= 53 interval=12 lastaster=0 maxasters=10 limitdec=60 end
Veja na linha em negrito como eu crio uma instância de uma classe, neste caso, a nave. O método “init” da classe “ship” será invocado e vai devolver uma instância já inicializada, que pode ser referida através da variável “nave”. A função “touched” será invocada sempre que o jogador tocar na tela. O parâmetro que ela recebe (“touch”) contém, entre outras coisas, as coordenadas de onde o jogador tocou na tela. Eu repasso isso para o método “touch” da nave. A função “draw” é um pouco extensa (e poderia ser refatorada), mas eu tenho que comentar algumas coisas sobre ela, afinal é o “Game loop”. -- This function gets called once every frame function draw() -- This sets a dark background color background(40, 40, 50) -- This sets the line thickness strokeWidth(5) -- Do your drawing here font(“MarkerFelt-Wide”)
Capítulo 4 - Prototipação do game — 45 fill(121, 249, 16, 255) fontSize(60) textMode(CORNER) text(“AsteroidNuts”, 10, HEIGHT-65) fill(91, 137, 229, 255) text(string.format(“Light years: %d”, lightyears),10,HEIGHT-120) text(string.format(“Energy: %d”,energy), 10, HEIGHT-185) if fimjogo==1 then fim() else contador = contador + 1 if contador >= limitdec then lightyears = lightyears + 10 energy = energy - 1 if energy <= 0 then fim() end contador=0 end newaster() nave:draw() if lightyears > 200 then posdec = 2 elseif lightyears > 500 then interval=6 posdec=10 maxasters=20 elseif lightyears > 1000 then limrandom=3 gatilho=1 interval=1 posdec=40 maxasters=30 limitdec=40 end for i,v in ipairs(asters) do if v.position.x < 0 then table.remove(asters,i)
46 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS currentasters = currentasters - 1
else
v.position.x = v.position.x - posdec v:draw() if asteroidhit(v) == 1 then
spriteMode(CORNERS) sprite(“Tyrian Remastered:Bullet Fire A”, v.position.x,v.position.y,v:finalx (),v:finaly()) if v.type == 2 then sound(SOUND_JUMP, 16037) energy = energy + 20 table.remove(asters,i) currentasters = currentasters - 1 else sound(SOUND_EXPLODE, 35632) energy = energy - 2 end if energy <= 0 then fim() end end end end end end
A função “draw” será chamada automaticamente pelo Codea a cada 60 hertz (1/60 de segundo). Eu tenho que verificar se acrescento mais asteroides e movimentar os que já existem, além de verificar se a nave colidiu com algum deles. Também verifico se os asteroides chegaram ao fim do seu caminho. A primeira coisa é incrementar o contador de “anos-luz” (lightyears) e decrementar o de energia, afinal, a nave se movimentou. Eu uso uma função “newaster()” para verificar se tenho que criar mais um asteroide (“newaster()”), e depois eu redesenho a nave, invocando seu método “draw”. function newaster() if math.random(1,limrandom) > gatilho then if currentasters < maxasters then
Capítulo 4 - Prototipação do game — 47
end
end
end
if lastaster >= interval then ast = Asteroid(upperlimit,lowerlimit) table.insert(asters,ast) currentasters = currentasters + 1 lastaster=0 else lastaster = lastaster + 1 end
Esta função “newaster” cria e insere um novo asteroide (“table. insert(aster,ast)”) se as seguintes condições forem atingidas: • Gatilho aleatório maior que 53, para dar um toque de imprevisão ao jogo; • O número de asteroides ativos for menor que 10; • O intervalo entre os asteroides for maior ou igual ao intervalo estabelecido (só pode lançar um novo depois de x asteroides); Na verdade, esta função faz parte da macro função de Estratégia do jogo, e deve ser bem elaborada para aumentar a jogabilidade. Mas, como é um protótipo, eu deixei assim mesmo. Depois, eu criei um “loop” para movimentar os asteroides, verificando se houve colisão de algum deles com a nave: for i,v in ipairs(asters) ... end
A tabela “asters” armazena os asteroides como um vetor dinâmico comum. Para percorrer esta tabela, usamos a forma de comando “for” com a função “ipairs(tabela)”. A cada iteração, o valor de “i” e “v” apontarão, respectivamente, para a posição do asteroide e para o objeto asteroide e eu posso referenciá-lo diretamente: v.position.x = v.position.x - posdec
A primeira coisa que eu faço no loop é verificar se o asteroide chegou ao fim do caminho, removendo-o da tabela: if v.position.x < 0 then table.remove(asters,i)
48 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
...
currentasters = currentasters – 1
Não dá problema algum remover um elemento durante a iteração. Se o asteroide ainda não chegou ao fim, eu decremento sua posição no eixo das abscissas, e verifico se houve colisão com a nave (função “asteroidhit”). Se houve, eu verifico se era um asteroide “bom” ou “ruim”, tomando a atitude correta (incrementar ou decrementar energia). Note que eu toco sons diferentes, dependendo do tipo de asteroide com o qual a nave colidiu. Para tocar um som, o Codea tem a função: sound (nome, raiz)
O parâmetro “nome” indica o nome do som a ser tocado, e a “raiz” o modifica aleatoriamente. Você deve experimentar com este segundo parâmetro até chegar ao som que deseja mostrar. O jogo chega ao fim sempre que a energia acaba. Durante o jogo, eu exibo algumas mensagens na tela. Eu uso a sequência de comandos abaixo: • font(nome da fonte): troca a família tipográfica (a fonte) dos caracteres; • fill(red, green, blue, alfa): estabelece a cor para a escrita; • fontSize(tamanho da letra): estabelece o tamanho da letra, em pontos (1/72 de polegada); • textMode(origem da posição do texto): especifica o tipo de coordenadas que vamos fornecer. CENTER significa que é o centro do texto e CORNER que é o canto inferior esquerdo do texto; • text(“texto a ser escrito”, x, y): os parâmetros “x” e “y” são as coordenadas do texto (veja “textMode”); Com este protótipo, eu posso “brincar” com o game, repensando a jogabilidade, as regras etc. A codificação em Lua é de baixa verbosidade e a API do Codea facilita tudo. Eu tenho um jogo quase completo que foi desenvolvido rapidamente.
Capítulo 4 - Prototipação do game — 49
Ilustração 19: O jogo funcionando
Infelizmente, o Codea só existe para iPad... Esta é uma limitação muito séria. Para começar, temos que ter um iPad para desenvolver o protótipo. Não podemos sequer rodar em um iPhone, quanto mais em um dispositivo Android. Neste caso, temos outra ferramenta excelente para prototipação: “Processing”.
Usando o “Processing” Como definir o “Processing”? Bem, para começar, podemos dizer que ele é um ambiente de programação, voltado para criação de gráficos e simulações. Na verdade, ele é mais do que isso, pois é também um game engine e um simulador de gráficos 3D. A primeira reação de quem vê os “demos” do processing pela primeira vez é surpresa. Sim, você também vai ficar de boca aberta, babando, ao ver como é possível criar simulações fantáticas, com tão pouco código. Vamos fazer isso agora. Para começar, baixe o “Processing” do seu site “processing.org”, e descompacte para a pasta que julgar conveniente. Abra a pasta onde instalou o “Processing” e execute o programa (no Windows é “Processing.exe”). Você verá a tela do PDE (Processing Development Editor), cuja simplicidade se assemelha muito ao Codea. Na verdade, o Codea foi inspirado pelo “Processing”.
50 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 20: A tela inicial do PDE
Selecione o menu “File / Examples...” e um diálogo com uma lista (“Java examples”) vai abrir ao lado. Expanda o nó “Demos” e depois o nó “Graphics” e selecione o item “planets” com um duplo clique. A aplicação “Planets” vai abrir no PDE. Você verá um botão com símbolo play na barra de botões do PDE, clique nele e observe a janela que vai se abrir.
Ilustração 21: O programa “Planets” em execução
Capítulo 4 - Prototipação do game — 51
Caro leitor, cara leitora, não é impressionante? Se analisarmos o código-fonte da aplicação (que tem dois arquivos), veremos que são poucos comandos para gerar um efeito sensacional. O “Processing” foi criado para ser uma ferramenta de prototipação de aplicações gráficas, como games e simulações. Só que, hoje em dia, é utilizado em aplicações profissionais mesmo. Com ele, podemos criar efeitos e animações com gráficos 2D e 3D, utilizando OpenGL. Ele permite desenvolvermos aplicações rapidamente, utilizando uma linguagem parecida com Java®, da Oracle. Na verdade, o “Processing” converte o código digitado em Java e o executa utilizando a JVM. O “Processing” possui alguns modos de execução, que permitem migrar o “executável” da sua aplicação para as plataformas que ele suporta: “Java”, “Javascript” e “Android”. Para mudar de plataforma, basta clicar no botão “JAVA” que está na barra superior e selecionar a nova plataforma. Porém, antes que você fique muito animado com o “Android”, quero esclarecer que esta característica ainda não funciona corretamente. Eu tentei muito tempo rodar uma aplicação “Processing” no Android e até consegui, porém, foi tão complicado que resolvi não recomendar no livro. Apesar do Emulador Android suportar OpenGL ES 2.0 (a partir da API 15), não consegui fazer funcionar. Somente com o dispositivo real conectado e, mesmo assim, tive diversos problemas. Mas estes problemas não invalidam o uso do “Processing” como ferramenta de prototipação de games, pois é só rodar no modo Java e tudo vai funcionar, inclusive o OpenGL.
Um tutorial rápido no “Processing” Bem, abra o PDE e verá uma aba com conteúdo em branco. É um “Sketch”, pronto para que você digite os comandos. Digite a linha abaixo: println(“Hello World”);
Ou então qualquer texto que considere interessante. Agora, clique no botão play na barra superior. Você verá uma pequena janela cinzenta, vazia, e na outra (a do PDE), o seu texto aparecerá na parte inferior (console). Agora, vamos fazer algo mais “bacana” no Processing. Vamos fazer uma nave andar pela tela. Para começar, crie um novo “Sketch” no PDE e salve. O “Processing” salva seus sketches dentro da pasta “Documents/Processing” (no Windows), criando uma pasta com o nome do primeiro sketch que você salvar. Os arquivos possuem a extensão “PDE”.
52 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Agora, dentro da pasta do seu sketch, crie uma pasta chamada “data” e copie o arquivo da nave, que fica em: “...\Codigo\AsteroidNuts_Processing\ Main\data\nave.png”. Para saber como baixar os arquivos-fonte do livro, veja na “Introdução”. A imagem deve ficar em uma subpasta “data”, dentro da pasta onde você salvou o seu sketch, certo? Agora, vamos digitar alguma coisa no PDE. Uma aplicação “Processing” tem estrutura bastante simples. Podemos digitar comandos imediatos (semelhante ao Javascript) ou podemos criar funções. Existem duas funções especiais, que são “callbacks”, ou seja, o “runtime” do “Processing” as invoca quando necessário: “setup()” e “draw()”. A função “setup()” é chamada no início do programa e apenas uma única vez. Nela, colocamos todo o código de inicialização. A função “draw()” é invocada quando é necessário criar um novo frame. O “Processing” serve para criarmos simulações “framed-based”, ou seja, aquelas semelhantes a desenhos animados. A cada intervalo de tempo, atualizamos o modelo, apagamos e redesenhamos a tela. Este intervalo é o “frame rate”, que é medido em FPS – Frames por Segundo. Podemos ajustar o intervalo com a função “frameRate()”. O FPS default é 60, ou seja, a função “draw()” será invocada 60 vezes por segundo. Vamos criar duas variáveis: uma para armazenar a imagem da nave e a outra para armazenar a posição dela. Antes de mais nada, o “Processing” utiliza o sistema cartesiano com a origem no canto superior esquerdo, ao contrário do Codea. Eis o código inicial: PImage nave; PVector posicao;
A classe PImage serve para armazenar uma imagem (um “sprite”) em memória e a classe PVector armazena um vetor. Estes são dois exemplos de comandos imediatos. Os comandos que ficam dentro de funções só serão executados quando ela for invocada. Agora, vamos criar nossa função “setup()”: void setup() { size(480, 320); nave = loadImage(“nave.png”); posicao = new PVector(10,80); }
O que estamos fazendo? Definimos o tamanho da nossa janela gráfica em 480 pixels de largura e 320 pixels de comprimento, depois carregamos na
Capítulo 4 - Prototipação do game — 53
nossa variável “nave” o “sprite” da nave, que está na subpasta “data”. Finalmente, iniciamos o vetor “posicao” indicando x = 10 e y = 80. Agora, vamos fazer com que a nave “ande” até o final da tela, um pixel a cada 1/60 de segundo. Para isto, temos que apagar a tela, desenhar a nave e incrementar a posição no eixo das abscissas. Se o valor for maior que a largura da tela (“width”) então temos que voltar à posição inicial. Eis o código da função “draw()”: void draw() { background(40, 40, 50); imageMode(CORNERS); image(nave, posicao.x, posicao.y, posicao.x + 100, posicao.y + 50); posicao.x++; if (posicao.x > width) { posicao.x = 10; } }
A função “background(R,G,B)” limpa a tela e a função “image” desenha a imagem, informando as suas coordenadas (canto superior esquerdo e canto inferior direito). Ok, tem uma coisa estranha aí: “imageMode(CORNERS)”. Estou forçando a função “image” a carregar a imagem usando as coordenadas do canto superior esquerdo e do canto inferior direito da imagem. Se você viu o tutorial em Codea, é semelhante à função “spriteMode()”.
Ilustração 22: O resultado do primeiro tutorial
54 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Criando o protótipo do “AsteroidNuts” com o “Processing” Agora, que já conhecemos um pouco do “Processing”, vamos criar o protótipo do jogo nele. O código-fonte está junto com os exemplos do livro (não precisa digitar nada, veja na “Introdução”). E é muito semelhante ao que fizemos para o “Codea”. Todo o código-fonte pode ser baixado, logo, não é necessário digitar os comandos. Veja na “Introdução”. Tem um arquivo “Asteroid.pde” (“...\Codigo\AsteroidNuts_Processing\Main\Asteroid.pde”) com a classe toda. Não importa se temos dois tipos de asteroides (bom e ruim), pois são apenas variações de propriedades de um asteroide. Nossa classe tem que armazenar os atributos de posição do Asteroide, além do seu tipo (“bom” ou “ruim”). No “Processing”, criamos classes de forma semelhante ao Java. Para começar, crie um sketch chamado “Main” e salve (file / new e file / save). Para criar uma nova classe, escolha o menu “File/new”, depois, save com o nome “Asteroid”, na mesma pasta do sketch “Main”. O “Processing” não tem gabarito algum para criarmos uma classe, então, escreva o seguinte texto: class Asteroid { Asteroid() { } void draw() { }
}
É como uma classe Java comum, só que não usamos modificadores de acesso. Note que este “esqueleto” tem um construtor (“Asteroid()”) e tem um método (“draw()”). O construtor é chamado quando criamos uma instância (um novo objeto) da classe, e o método “draw()” só é chamado quando nós o invocamos (não confunda com a função “draw()”, do arquivo principal). O construtor será invocado sempre que quisermos pegar um novo objeto da nossa classe, por exemplo: Asteroid ast1 = new Asteroid();
Para começar, vamos trabalhar nosso construtor. Primeiramente, temos que pensar em como iniciar o Asteroide. Cada Asteroide deverá surgir no canto
Capítulo 4 - Prototipação do game — 55
direito da tela, em uma altura variável. Haverá uma faixa de valores no eixo das ordenadas (Y), onde os asteroides poderão surgir. Eu não quero usar a tela toda, pelo menos neste protótipo. Então, faz sentido passarmos em passar os limites para o construtor da classe, de modo que este possa gerar um asteroide dentro da faixa esperada. class Asteroid { PVector position; PVector dimension; PImage imagem; int type; Asteroid(int upper, int lower) { int tipo = int(random(1,101)); position = new PVector(100,50); dimension = new PVector(50,50); position.x = width - 100; float rPos = random(upper, lower); position.y = int(rPos); if (tipo > 80) { // asteroide bom type = 2; imagem = loadImage(“bom.png”); } else { type = 1; imagem = loadImage(“ruim.png”); } }
Todo o código colocado entre o “{“ e o “}” da classe, pertence a ela. Pode ser uma propriedade ou um método. Processing é uma linguagem de tipagem forte, ou seja, cada variável tem seu tipo atribuído estaticamente (e não pode mudar). Passamos dois parâmetros para o construtor: “upper” – limite superior no eixo das ordenadas, e “lower” – limite inferior. Nós criamos três propriedades: • “position”: do tipo “PVector”, que armazena um vetor com coordenadas bidimensionais (“x” e “y”). Armazena a posição atual do asteroide; • “dimension”: igualmente “PVector”, armazena a largura e altura da caixa que contém a figura do asteroide;
56 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
• “type”: do tipo “int” (Java), que armazena o tipo do asteroide, se ele é “bom” (type = 2) ou “ruim” (type = 1). É claro que isto poderia ser resolvido melhor por hierarquia ou composição, mas é um protótipo, logo, é descartável; • “imagem”: do tipo “PImage”, que armazena uma imagem (um “Sprite”). Nossa estratégia para decidir se vamos criar um asteroide “bom” ou “ruim” é apenas aleatória. Poderíamos levar alguns fatores em consideração, como o nível de energia do jogador e a quantidade de acertos que ele tem, mas, para efeito de protótipo, vamos deixar assim. Geramos um número aleatório entre 1 e 101 (“int(random(1,101))” se ele for maior que 80, nós o transformamos em “bom”. Inicialmente, calculamos a posição horizontal (abiscissas) em “width – 100” (a variável global “width” retorna a largura da tela, e “height”, a altura). E calculamos a posição vertical (ordenadas) com um número aleatório entre o limite inferior e o superior (“random(upper, lower)”). Agora, demos que desenhar um asteroide. O melhor local para fazer isto é no método “draw”. Nós invocaremos o método “draw” sempre que desejarmos redesenhar um objeto Asteroid específico. void draw() { imageMode(CORNERS); image(imagem, position.x, position.y, position.x + dimension.x, position.y + dimension.y); }
A primeira coisa que fizemos foi mudar o modo de desenhar um “sprite”, que é uma imagem carregada na tela. Eu prefiro trabalhar com as coordenadas dos cantos (superior esquerdo e inferior direito), então eu mudei o modo de desenho para CORNERS (“imageMode(CORNERS)”), que significa: “x” e “y” serão o canto superior esquerdo da “caixa” que contém o sprite e os parâmetros “w” e “h” serão, respectivamente, as coordenadas do canto inferior direito da mesma. Lembre-se: O “Processing” possui um sistema de coordenadas diferente do “Codea”! Se for do tipo “2”, é um asteroide “bom”, logo, eu uso uma imagem do “OpenClippart.org”, que representa uma esfera brilhante (com uma estrela dentro). Eu uso a função “image” para desenhar a figura do asteroide na tela, na posição especificada (propriedade “position”) e do tamanho especificado
Capítulo 4 - Prototipação do game — 57
(propriedade “dimension”). O primeiro parâmetro é o nome da figura, o segundo e o terceiro, as coordenadas do canto superior esquerdo, e o quarto e o quinto, as coordenadas do canto inferior direito. Finalmente, temos dois métodos “finalx” e “finaly”, que retornam as coordenadas do canto superior direito do asteroide, para facilitar seu uso. Agora, precisamos representar a nave (arquivo “ship.pde”). Vamos criar uma classe da mesma maneira. Em seu construtor, nós criamos as mesmas propriedades do asteroide, exceto o “type”. Mas colocamos o “lower” e o “upper” como propriedades da nave. Veja o método “init”: class ship { PVector position; PVector dimension; PImage imagem; int upper; int lower; ship() { position = new PVector(10,80); dimension = new PVector(100,50); position.y = (height - dimension.y)/2; upper = int(position.y - 4 * dimension.y); lower = int(position.y + 5 * dimension.y); imagem = loadImage(“nave.png”); }
O método “draw” é igualmente simples:
void draw() { imageMode(CORNERS); image(imagem, position.x, position.y, position.x + dimension.x, position.y + dimension.y); }
Desenhamos da mesma maneira que o asteroide. E a nave também tem os métodos “finalx” e “finaly”. Na verdade, ambos (Asteroid e ship) poderiam ser derivadas de uma classe ancestral comum “GameObject”, mas eu não quis complicar as coisas, pois é um protótipo. Um método “callback” importante é o “clicked”, que será invocado quando o usuário clicar com o mouse. void clicked(int x, int y) { if (y >= upper && y <= lower) {
58 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
}
}
position.y = y;
Eu simplesmente pego a altura do clique do jogador e posiciono a nave nela. Note que não é necessariamente o clique na nave, mas na tela toda. Eu poderia sofisticar este controle, mas, como é um protótipo, eu preferi deixar tudo mais simples. Na verdade, quem invoca este método é o meu arquivo “Main”, já que ele não será invocado automaticamente pelo “Processing”. Agora, chegou o momento de criar a mecânica do jogo. Eu poderia ter usado os recursos de física cinética do Codea (fornecidos pela biblioteca Box2D), com o objeto “body”. Assim, toda a parte de movimento e colisão seria automática. Porém, considerei que o protótipo ficaria complexo demais e, na verdade o jogo é muito simples na parte de física. Então, eu mesmo criei a física necessária para movimentar e verificar colisões. No arquivo “Main”, eu crio algumas variáveis globais para controle do jogo, as quais nem vou mencionar diretamente, exceto quatro: • “ship nave;”; • “ArrayList asters;”; • “PFont letraTitulo;”; • “PImage explosao;”;
A variável “asters” é do tipo “ArrayList”, que é um array dinâmico em Java. E a variável “nave” é um objeto, que representará um objeto real da classe “ship”. A variável “letraTitulo” representa uma “fonte” ou definição tipográfica de caractere. A variável “explosao” representa a imagem de uma explosão, que vai aparecer quando a nave tocar em um asteroide “ruim”. A tabela “asters” vai armazenar os asteroides ativos em determinado momento (aqueles que não chegaram ao fim do caminho: o canto esquerdo da tela). A função “setup” será invocada automaticamente pelo “Processing”, e inicializa as variáveis do game. void setup() { size(320, 480); upperlimit = (height) / 2 - 100; lowerlimit = (height) / 2 + 100; nave = new ship (); // Init vars lightyears=0;
Capítulo 4 - Prototipação do game — 59 energy=200; contador=0; fimjogo=0; posdec=1; limrandom=60; inirandom=1; gatilho= 53; interval=12; lastaster=0; maxasters=10; limitdec=60; asters = new ArrayList(); explosao = loadImage(“explosao.png”); letraTitulo = loadFont(“Andy-Bold-14.vlw”); }
Aqui valem algumas notas importantes sobre arquivos de recursos (imagens e fontes). Todos os recursos em uma aplicação “Processing” devem ficar dentro da subpasta “data”, dentro da pasta onde estão os “sketches” que você vai rodar. Logo, todas as imagens devem estar dentro dela. Esta estrutura já está desta forma no zip do código-fonte do livro. Outra coisa importante é a fonte. Se você for escrever alguma coisa, deve criar um arquivo de fonte no formato “vlw”, que o “Processing” usa. Existe um arquivo “vlw” para cada combinação de tipo e tamanho de letra. Para criar um arquivo “vlw”: 1. Abra o menu “tools / create font”; 2. Selecione o tipo de letra e o tamanho; 3. Clique em “Ok”; 4. Copie o arquivo “vlw” gerado (fica na pasta do próprio “Sketch” para a subpasta “data”). A função “mouseClicked” será invocada sempre que o jogador clicar na tela. Quando isto acontece, eu repasso as coordenadas do clique para o método “clicked” da nave. A função “draw” é um pouco extensa (e poderia ser refatorada), mas eu tenho que comentar algumas coisas sobre ela, afinal é o “Game loop”. void draw() { background(40, 40, 50); strokeWeight(5); textFont(letraTitulo); fill(121, 249, 16, 255);
60 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS textSize(24); text(“AsteroidNuts”, 10, 25); fill(91, 137, 229, 255); text(“Light years: “ + lightyears,10,50); text(“Energy: “ + energy,10, 75); if (fimjogo == 1) { fim(); } else { contador = contador + 1; if (contador >= limitdec) { lightyears = lightyears + 10; energy = energy - 1; if (energy <= 0) { fim(); } contador=0; } newaster(); nave.draw(); if (lightyears > 200) { posdec = 2; } else if (lightyears > 500) { interval = 6; posdec = 10; maxasters = 20; } else if (lightyears > 1000) { limrandom = 3; gatilho = 1; interval = 1; posdec = 40; maxasters = 30; limitdec = 40; } for (int i = asters.size()-1; i > 0; i--) { Asteroid v = (Asteroid) asters.get(i);
Capítulo 4 - Prototipação do game — 61 if (v.position.x < 0) { asters.remove(i); currentasters = currentasters - 1; } else { v.position.x = v.position.x - posdec; v.draw(); if (asteroidhit(v) == 1) { imageMode(CORNERS); image(explosao, v.position.x,v.position.y,v. finalx(),v.finaly()); if (v.type == 2) { energy = energy + 20; asters.remove(i); currentasters = currentasters - 1; } else { energy = energy - 2; } if (energy <= 0) { fim(); } } } } } }
A função “draw” será chamada automaticamente pelo “Processing” a cada 60 hertz (1/60 de segundo). Eu tenho que verificar se acrescento mais asteroides e movimentar os que já existem, além de verificar se a nave colidiu com algum deles. Também verifico se os asteroides chegaram ao fim do seu caminho. A primeira coisa é incrementar o contador de “anos-luz” (lightyears) e decrementar o de energia, afinal, a nave se movimentou. Eu uso uma função “newaster()” para verificar se tenho que criar mais um asteroide (“newaster()”), e depois eu redesenho a nave, invocando seu método “draw”. void newaster() { if (random(1,limrandom) > gatilho) {
62 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
mit);
}
}
if (currentasters < maxasters) { if (lastaster >= interval) { ast = new Asteroid(upperlimit,lowerli asters.add(ast); currentasters = currentasters + 1; lastaster=0;
}
} else { lastaster = lastaster + 1; }
Esta função “newaster” cria e insere um novo asteroide (“table. insert(aster,ast)”) se as seguintes condições forem atingidas: • Gatilho aleatório maior que 53, para dar um toque de imprevisão ao jogo; • O número de asteroides ativos for menor que 10; • O intervalo entre os asteroides for maior ou igual ao intervalo estabelecido (só pode lançar um novo depois de x asteroides); Na verdade, esta função faz parte da macro função de Estratégia do jogo, e deve ser bem elaborada para aumentar a jogabilidade. Mas, como é um protótipo, eu deixei assim mesmo. Depois, eu criei um “loop” para movimentar os asteroides, verificando se houve colisão de algum deles com a nave: for (int i = asters.size()-1; i > 0; i--) { ... }
A tabela “asters” armazena os asteroides como um vetor dinâmico comum. A cada iteração, o valor de “i” apontará, para a posição do asteroide atual. A primeira coisa que eu faço no loop é verificar se o asteroide chegou ao fim do caminho, removendo-o da tabela: if (v.position.x < 0) { asters.remove(i); currentasters = currentasters - 1; }
Capítulo 4 - Prototipação do game — 63
Não dá problema algum remover um elemento durante a iteração. Se o asteroide ainda não chegou ao fim, eu decremento sua posição no eixo das abscissas e verifico se houve colisão com a nave (função “asteroidhit”). Se houve, eu verifico se era um asteroide “bom” ou “ruim”, tomando a atitude correta (incrementar ou decrementar energia). O jogo chega ao fim sempre que a energia acaba. Durante o jogo, eu exibo algumas mensagens na tela. Eu uso a sequência de comandos abaixo: • textFont(): estabeleço qual é o objeto PFont a ser utilizado para a próxima escrita; • fill(R, G, B, A): determino a cor do texto; • textSize(): limito o tamanho do texto (em pontos); • text(“”, x, y): escrevo o texto nas coordenadas indicadas; Com este protótipo, eu posso “brincar” com o game, repensando a jogabilidade, as regras etc. A codificação é de baixa verbosidade, e a API do “Processing” facilita tudo. Eu tenho um jogo quase completo que foi desenvolvido rapidamente.
Ilustração 23: O protótipo do “Asteroid Nuts” no Processing
64 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Crie vários protótipos Eu sugiro que você crie vários protótipos e evite apagar os anteriores. Mantenha várias versões, testando novas maneiras de jogar e novos cenários e elementos. Depois, você pode fazer um “corte”, eliminando as ideias que não gostou. A facilidade de criar protótipos torna o projeto do game mais objetivo, pois, uma vez que você tenha testado todos eles, poderá escolher as ideias mais interessantes e se concentrar apenas nelas.
Capítulo 5 Física de games Física é uma macro função muito importante em games de ação, pois serve para aumentar a jogabilidade, aumentando a percepção de realidade e o envolvimento do jogador. A física de um game deve lidar com problemas como: força, aceleração, movimento e colisão, tentando fazer com que os Game Objects se comportem aproximadamente como os modelos reais. Os exemplos são incrementais Eu vou mostrar vários exemplos neste capítulo e todo o código-fonte está disponível para você, logo, não necessitará digitar coisa alguma. Porém, eu quero fazer uma observação sobre os exemplos: o objetivo é mostrar como aplicar cálculos de física aos games. Neste momento, eu não estou preocupado com a precisão do Game Loop ou com quaisquer outros detalhes. Logo, antes de usar os exemplos como base para o seu game, tenha em mente que existem mais aspectos a serem analisados, então, eu sugiro que você leia até o final ANTES de sair criando seu jogo definitivo. Rode todos os exemplos Este capítulo é muito grande, mas é extremamente importante para que você conheça bem a física de jogos. Todos os exemplos estão junto com o código-fonte que acompanha o livro. Eu recomendo que você baixe todos e rode em seu computador.
66 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Os primórdios
Ilustração 24: O jogo “Gorilla.bas”, que vinha com o Microsoft QBasic
Um dos exemplos mais antigos de física que eu me lembro é o do jogo “Gorilla.bas”, que vinha com o Microsoft QBasic. Na verdade, eu já o conhecia de um produto mais antigo: o Microsoft QuickBasic, que eu utilizava para desenvolver sistemas na década de 80. A física era bem simples e basicamente lidava com o movimento de um projétil (uma banana), levando em conta a força da gravidade. Se pararmos para pensar, não é muito diferente do que o “Angry Birds” (Rovio - www.rovio.com/index.php?page=angry-birds) original fazia: atirar um pássaro, que viaja em trajetória influenciada pela gravidade. Quanto mais força, mais larga a parábola. E você tinha que acertar ou derrubar os porcos. A mecânica é parecida com a do “Gorilla.bas”, talvez um pouco mais precisa.
Conceitos básicos Nem todo game é tão exigente na física quanto os jogos de ação. Existem games que sequer possuem qualquer tipo de física. É o caso do game que estou desenvolvendo para Facebook: o RandoSystem, que é um quebra-cabeças baseado em labirinto.
Capítulo 5 - Física de games — 67
Ilustração 25: Um jogo que dispensa a física
Porém, se você quer criar um jogo onde os Game Objects se movam e colidam, deve prestar atenção à física, de modo a aumentar o envolvimento do jogador. O jogo que eu descrevi no meu livro anterior, “Mobile Game Jam” (www. mobilegamejam.com) era muito mais dependente de física.
Ilustração 26: O jogo “BueiroBall”, do livro “Mobile Game Jam”
No “BueiroBall” (eu sei, o nome poderia ser melhor) você tem que “encaçapar” as bolas em determinada ordem, senão perde pontos (neste caso, as pretas só podem entrar depois das coloridas) e você joga usando o acelerômetro,
68 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
ou seja, inclinando o seu dispositivo. Neste jogo, a física é fundamental, pois temos que acrescentar realismo ao movimento e às colisões. As bolas devem se movimentar de acordo com a inclinação, acelerando ou diminuindo conforme a posição do dispositivo. E também tive que tratar a colisão, ou seja, o que acontece quando duas ou mais bolas colidem. Qual é o vetor de movimento resultante etc.
Aceleração e movimento A parte que estuda isso na física é a cinemática. Eu não vou desperdiçar seu tempo explicando conceitos de física, mas, se quiser saber, recomendo a série “física de video game”, do portal “The Code Bakers” (http://www.thecodebakers.org/search/label/F%C3%ADsica). O grande problema de jogos de movimento dinâmico é calcular onde você deve desenhar a figura no próximo frame. Se você utiliza uma física simples, como a que vimos no protótipo do “AsteroidNuts” (capítulo anterior), isto não é problema. Basta decrementar ou incrementar o valor da coordenada correspondente à direção do movimento e pronto. No caso do “AsteroidNuts”, estamos no espaço, e consideramos a aceleração constante. Mas poderíamos criar efeitos interessantes, como o puxão da gravidade, por exemplo. Para calcular a posição de um objeto com relação a um plano de coordenadas cartesianas, podemos usar o método de Verlet (http://pt.wikipedia.org/wiki/ M%C3%A9todo_de_Verlet ou http://www.fisica.ufjf.br/~sjfsato/fiscomp1/node40. html). A fórmula básica é: x(t + Δt) = (2 – f)x(t) – (1 – f)x(t - Δt) + a(t)( Δt)2 Podemos calcular a posição em cada eixo, informando as forças que foram aplicadas a eles (impulso e gravidade, por exemplo). É isso o que fiz no game “Ataque das formigas marcianas”, publicado no “The Code Bakers” (http://www.thecodebakers.org/2012/04/fisica-de-videogame-3-finalmente-um.html). O código-fonte do Game está no Google Code (http://code.google.com/p/ataque-formigas-marcianas/), e ele usa a licença Apache 2.0 (Open Source).
Capítulo 5 - Física de games — 69
Ilustração 27: O jogo “Ataque das formigas marcianas
No jogo das “formigas”, o código que controla o movimento e aceleração é calculado na classe que representa a “Bola”: /* * Esta classe representa uma bola * Podemos criar mais de uma... */ class Bola { double altura; double alturaAnterior; double alturaLimite; double limiteSuperior; double aceleracao; double velocidade; double dt; double forcaGrav; double massa; boolean parada = true; boolean descendo = true; double e = 0.50; // Coeficiente de restitui ‹o (pode variar) float x; float y;
70 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS Bola() { }
reset();
void reset() { limiteSuperior = altura = alturaAnterior = alturaLimite = aceleracao = velocidade = dt = forcaGrav = massa = descendo = }
PISO; limiteSuperior; altura; 0; 0.0d; 0.0d; 01.d; -9.8d; 1.0d; true;
void atualizar() { altura = altura + velocidade * dt + (aceleracao * Math.pow(dt, 2)) / 2.0d; double vel2 = velocidade + (aceleracao * dt) /2.0d; double aceleracao = forcaGrav / massa ; velocidade = vel2 + (aceleracao * dt) / 2.0d; }
}
E, quando chegamos ao “chão”, nossa velocidade vai inverter, com uma aceleração proporcional à deformação causada pela colisão.
Colisão Em física, temos dois tipos básicos de colisão: elástica e inelástica (http:// www.coladaweb.com/fisica/mecanica/colisao-elastica-e-inelastica). Eu postei um artigo sobre isso no portal “The Code Bakers” (http://www.thecodebakers. org/2011/06/fisica-de-videogame-2-colisoes.html). O tipo de material dos dois ob-
jetos pode determinar o tipo de colisão que teremos: • Colisão elástica: ambos os corpos se deformam e se expandem após o choque, resultando na mesma energia cinética; • Colisão parcialmente elástica: os corpos perdem parte da energia cinética, que é transferida para trabalho (barulho, calor, deformação permantente - plástica);
Capítulo 5 - Física de games — 71
• Colisão inelástica: toda a energia cinética é transferida para trabalho; O que significa isso? Bem, quando dois Game Objects colidem, dependendo do tipo de material, parte da energia cinética é transferida e um deles (ou ambos) podem perder velocidade. É o que acontece no jogo “Ataque das formigas marcianas” quando a bola atinge o solo, “quicando” e subindo até uma altura menor que a inicial. Ela vai “quicar” algumas vezes até parar. Em outros tipos de game (como o “BueiroBall”) nós não precisamos pensar nisso, pois é irrelevante. Mas, em games onde o “quique” e a deformação provocados pela colisão são importantes, é necessário calcular o que acontece depois da colisão. Cada tipo de material tem um CR - “Coeficiente de retribuição” (http:// www.thecodebakers.org/2011/06/fisica-de-videogame-2-colisoes.html), que influencia no cálculo do movimento de “quique” e da deformação causados pela colisão. No caso do “Ataque das Formigas”, eu estou desprezando a deformação e utilizando o CR apenas para calcular a força que será aplicada à bola para subir novamente. if (bola.altura <= 0) { // A bola bateu no ch‹ão bola.altura = 0; bola.x = MEIO; bola.y = (float)(PISO - bola.altura); canvas.drawBitmap(bitmap, bola.x, bola.y, null); if (!bola.parada) { verificaColisao(canvas); } double novaAltura = Math.pow(bola.e, 2) * bola.alturaAnterior; bola.velocidade = Math.sqrt(2 * (-bola.forcaGrav) * novaAltura); bola.alturaAnterior = novaAltura; ...
O CR que estou usando (0,50) provoca um alto valor de restituição, permitindo que a bola “quique” alto. Se usarmos algo como bola murcha caindo em argila, teremos uma baixíssima restituição, já que os dois materiais usarão a maior parte da energia cinética no trabalho de deformação, absorvendo o impacto.
72 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Há outro fator a ser considerado: a direção dos objetos após a colisão. No caso do “BueiroBall”, estou simplesmente inverto a velocidade nos dois eixos, que não é exatamente o que aconteceria na vida real, mas dá um efeito aceitável para o jogador. No caso do “Ataque das formigas marcianas”, estou desprezando isso e fazendo a bola simplesmente subir na mesma direção. Porém, em uma colisão mais realista, a trajetória dos objetos seria afetada por: • Ângulo da trajetória dos objetos; • Massa dos objetos; • Velocidade dos objetos; • CR dos objetos; Ou seja, há muitas variáveis a serem consideradas, e, dependendo do jogo, pode não ser interessante considerar isso tudo. Você tem que analisar o valor que a colisão realista vai agregar ao seu game. No protótipo de game “AsteroidNuts” a colisão é tratada de forma muito simples. Ao tocar em um asteroide, a nave perde ou ganha energia (no jogo), mas ambos (asteroide e nave) não são deformados e nem têm sua trajetória alterada. Esta é uma decisão que temos que tomar, caso sigamos em frente com este projeto.
Deteção de colisão Se temos um jogo onde existem objetos em movimento, então também teremos que detetar colisões entre eles. Note que colisão nem sempre significa desastre, por exemplo, quando o “herói” pula para uma plataforma, ocorre uma colisão. Eu criei alguns artigos sobre deteção de colisão no portal “The Code Bakers” (http://www.thecodebakers.org/search/label/Game), mas é um assunto complexo e difícil de ser resolvido em desenvolvimento de games. Todo Game Object que está em uma determinada camada pode ser afetado por outros objetos da mesma camada. Por exemplo, a nave e os asteroides no protótipo “AsteroidNuts” estão na mesma camada e podem se colidir. Como saber se dois objetos colidiram? Se eles se tocarem ou se tiverem pontos em comum, então podemos deduzir que houve colisão. Todo Game Object tem sua imagem exterior, logo, podemos assumir que esta é a sua “fronteira”? Imagine o GO da figura seguinte. Como detectaríamos colisão de um objeto como este?
Capítulo 5 - Física de games — 73
Ilustração 28: Um Game Object de forma compexa
Realmente, teríamos que delimitar um polígono em volta do Game Object, de modo a testarmos se houve colisão com outro GO. Podemos simplificar muito o cálculo de colisão se utilizarmos polígonos simples, como retângulos.
Ilustração 29: Polígono de colisão retangular
Neste caso, a colisão se resume a determinar se os dois retângulos se intercetam. E isto pode ser feito facilmente, tanto no Android, como no iOS. // Android private boolean colisao (Rect r1, Rect r2) { if (r1.intersect(r2)) { return true; } return false; } // Objective C – iOS - (BOOL) colisao: (CGRect) r1 outro: (CGrect) r2 { if (CGRectIntersectsRect(r1,r2)) { return YES; } return NO; }
74 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
No Android, temos a classe “android.graphics.Rect”, que possui métodos para lidar com retângulos, e no iOS temos a estrutura CGRect, que representa um retângulo, além da função “CGRectIntersectsRect()”, que verifica se dois retângulos passados se intercetam. É claro que estamos falando de games 2D, pois para games 3D tudo muda. O problema de usar polígonos retangulares pode ser notado na figura seguinte.
Ilustração 30: Nem sempre retângulos colisores dão o melhor resultado
Podemos notar que, dependendo da forma e do tamanho dos objetos, um retângulo colisor (o polígono que envolve o GO) pode dar um resultado ruim, detetando colisões que poderiam ser evitadas, o que compromete a jogabilidade. Para resolver este problema, podemos usar outras formas ou “malhas” colisoras.
Ilustração 31: Outros tipos de colisores
Porém, temos outro problema: como vamos detetar colisões? Bem, dependendo da forma dos colisores, existem várias maneiras.
Capítulo 5 - Física de games — 75
Se ambos os objetos usarem colisores circulares, é simples, pois basta comparar a distância entre seus centros e os seus tamanhos somados. Se forem círculos e polígonos, podemos pegar o segmento de reta do polígono que está à frente do círculo, e calcular a distância entre o centro do círculo e a reta, comparando com o raio, ou qualquer variante deste algoritmo. Eu mesmo dei solução para alguns destes problemas em: http://www.thecodebakers.org/2012/10/fisica-de-videogame-4-deteccao-de.html, e inclusive criei uma biblioteca em Java para isto: http://fisica-videogame.googlecode.com/ files/colisoes3.zip. Se ambos forem polígonos, podemos calcular a interseção de polígonos convexos. Um polígono P é considerado convexo se qualquer segmento de reta definido por dois pontos dentro de P se situa dentro de P. Podemos usar o método de projeção para sabermos se dois polígonos se intercetam: 1. Selecionamos um dos segmentos do polígono colisor do PLAYER OBJECT PO); 2. Selecionamos os GOs que estão na mesma faixa Y, ou seja, aqueles que podem estar colidindo com o PO; 3. Selecionamos os GOs que estão mais próximos (X) dentro da faixa; 4. Para cada GO selecionado, verificamos a interseção; A verificação da interseção em si é feita pelo seguinte algoritmo: 1. Pegamos um dos segmentos de reta de um polígono; 2. Calculamos um vetor Ortogonal a ele; 3. Calculamos o produto vetorial do vetor ortogonal por cada vetor de cada outro polígono, usando os pontos iniciais e finais. Isto nos dará valores numéricos reais; 4. Pegamos o máximo e o mínimo de cada produto; 5. Projetamos no segmento. Se houver interseção entre todos os segmentos, então os polígonos estão se tocando ou se sobrepondo. Uma boa referência para isto é: http://gamemath.com/2011/09/detectingwhether -two-convex-polygons-overlap/. Infelizmente, dependendo da complexidade dos polígonos, o cálculo pode demorar demais. Eu propus uma alternativa no post: http://www.thecodebakers. org/2012/11/fisica-de-videogame-5-multirectangle-cd.html, que consiste em dividir o problema em simples interseção de retângulos. Para isto, dividimos as imagens em retângulos colisores, conforme a figura seguinte. Neste caso, o segredo é manter o mapeamento entre a posição atual do GO e a de cada retângulo colisor.
76 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 32: Uma opção mais simples de colisão de objetos complexos
A deteção de colisão é um problema crítico da macrofunção de física de qualquer game. Falhas na detecção de colisão podem comprometer a jogabilidade, logo, eu recomendo duas abordagens: • Se o seu game é muito simples, use retângulos ou círculos e ponto final; • Se o seu game é mais complexo, use um engine de física.
Engines de física Para facilitar o desenvolvimento de jogos de ação, foram criadas várias bibliotecas de funções e cálculos de física, chamadas de “Physics engines” (Engines de física, é a forma que eu prefiro). Existem vários engines, sejam gratuitos, pagos, 2D ou 3D. Todos eles servem para calcular: aceleração, movimento, colisão e seus efeitos. É perfeitamente possível criar um game de ação sem utilizar um engine de física. Na verdade, eu fiz isso em alguns projetos de jogos. O fator a ser analisado na decisão de uso de um engine é: qual é o valor agregado por uma física mais realista? É certo que uma física mais realista vai demandar maiores recursos, como: CPU e/ou memória, então, temos que ter certeza de que seu uso vai agregar valor proporcional ao jogador, caso contrário, estaremos introduzindo o risco de “lag” à toa.
Bullet Physics Library É um engine de física open source desenvolvido por Erwin Coumans. Pode ser baixado do site: http://bulletphysics.org/wordpress/, e sua documen-
Capítulo 5 - Física de games — 77
tação pode ser acessada no link: http://bulletphysics.com/ftp/pub/test/physics/
Bullet_User_Manual.pdf.
O Bullet, além de open source, é multiplataforma. Ele é feito em C++ e pode ser usado em: Sony PLAYSTATION 3, Microsoft XBox 360, Nintendo Wii, PC, Linux, Mac OSX, Android e Apple iOS. Além disto, pode ser integrados a softwares de edição 3D, como o Maya, da AutoDesk, e o Blender. Para ter uma ideia de como o Bullet é profissional, veja só uma pequena lista de games que o utilizam: • Toy Story 3 (Disney); • GTA IV (Rockstar Games); • Blood Drive (Activision); Uma característica importante do Bullet é que a partir da versão 2.80 os cálculos de física de corpos sólidos são executados pela GPU, utilizando o OpenCL (http://www.khronos.org/opencl/), o que é um grande recurso para evitar “lags” devido a cálculos complexos de física.
Chipmunk physics engine O Chipmunk é um engine completo, também open source, e desenvolvido por Scott Lembcke. Seu site é http://chipmunk-physics.net/. Ele é utilizado em vários games e ferramentas, como o Cocos2D (http://www.cocos2d-iphone.org/). A versão “pro” é paga, mas tem alguns recursos interessantes, como o “Autogeometry”, que permite criar a malha de colisão diretamente a partir de imagens.
Box2D O Box2D é uma biblioteca de funções físicas para games totalmente Open Source e gratuita, desenvolvida em C++ por Erin Catto. Seu site é: http://box2d.org. É uma das mais famosas, talvez por ser uma das mais simples. Não possui recursos sofisticados, mas permite calcular movimento e colisão de forma bem realista. Ela possui versões para várias linguagens, como o JBox2D (http://www. jbox2d.org/), que é bem fiel à biblioteca original. Algo que ajudou a criar a fama do Box2D foi sua utilização no “blockbuster” Angry Birds (Rovio). Como eu não posso abordar todos os engines de física em apenas um único livro, escolhi o Box2D, pois, além da preferência pessoal, é muito comentado hoje em dia.
78 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Física com Box2D Antes de continuarmos, é necessário fazer alguns comentários sobre o Box2D. Para começar, ele foi feito em C++ e, para utilizá-lo dentro de aplicações Android, nós teríamos que usar JNI para nos comunicarmos com ele, o que dificultaria um pouco o desenvolvimento. Então, eu optei por ensinar Box2D usando o JBox2D em Java mesmo. Depois, eu explico como isso funciona com o Box2D e o iOS. Assim, muitas vezes eu vou me referir apenas ao Box2D, neste caso, tudo vale também para o JBox2D. A segunda coisa que precisamos saber é que o Box2D não desenha na tela. Ele apenas calcula posições, sendo sua a responsabilidade de desenhar o que o Box2D calculou. A terceira coisa é que o Box2D não faz referência à origem do plano cartesiano que usa. Na verdade, ele vai trabalhar bem de qualquer forma, pois, para ele não existe canto superior esquerdo. Se você vai apresentar alguma imagem baseado nas coordenadas calculadas pelo Box2D, você tem que ajustá-las para a sua “viewport”, ou seja, se está usando um framework gráfico que coloca a origem do eixo das ordenadas no canto superior esquerdo, então você tem que fazer a translação da coordenada “y”. Finalmente, temos que conversar sobre as unidades e medidas. O Box2D não assume nenhuma unidade, embora esteja ajustado para trabalhar com metros. Por exemplo, um retângulo de 50 x 30 o que significa? Pode ser um prédio de 50 metros de largura por 30 de altura, ou pode ser uma caixa de sapato, você é que decide. Em seu manual (http://box2d.org/manual.pdf), ele recomenda que você mantenha seus objetos dinâmicos entre 0,1m e 10m de tamanho (altura e largura), e os objetos estáticos até 50m. É recomendável que você faça a tradução entre as medidas do Box2D e as medidas reais na tela, evitando associar diretamente as unidades com pixels. Por exemplo, você criou uma caixa com largura 50 e altura 30 no Box2D e assume que serão 50 por 30 pixels. Crie os objetos com um valor de unidade que seja fácil de trabalhar, calculando a escala para exibição depois. Isto é muito importante: o Box2D trabalha melhor com objetos dinâmicos entre 0.1 e 10 metros. Objetos estáticos podem ser maiores. Você tem que criar um fator de escala para traduzir para a tela. NÃO CONVERTA DIRETAMENTE METROS EM PIXELS!
Capítulo 5 - Física de games — 79
Preparando o laboratório Considerando que nem todo mundo tem um Mac ao seu dispor, achei melhor estudarmos os conceitos do Box2D com um laboratório, baseado no JBox2D e em Java. As diferenças são desprezíveis, e podemos trabalhar em qualquer plataforma Desktop. Depois, quando formos estudar a aplicação em dispositivos móveis, nós usaremos o Box2D para iOS e o JBox2D para o Android. Eu recomendo que você baixe e leia o manual do Box2D (C++): http:// box2d.org/manual.pdf. A definição de todas as funções está nele. Aqui, eu apenas listo um resumo. Se quiser o modelo de objetos em Java, leia o JavaDoc do JBox2D, que vem dentro da distribuição. Eu aconselho você a comprarar sempre os dois. Eu recomendo que você baixe os dois: o Box2D e o JBox2D. Para baixar o Box2D, acesse o link: http://code.google.com/p/box2d/downloads/list e baixe a última versão (no meu caso é 2.2.1). E, para baixar o JBox2D, acesse o link: http://code.google.com/p/jbox2d/downloads/list e baixe a última versão (2.1.2.2). Descompacte os dois arquivos em pastas separadas. O código-fonte deste projeto está junto com os fontes do livro. Veja no capítulo de introdução. A pasta é “...\Codigo\JBox2DLab\jbox2lab.zip”. É um projeto “eclipse” compactado, logo, você pode importar para sua “workspace”. Nota: Configure sua “Workspace” eclipse para trabalhar com caracteres UTF-8. Abra o menu “Window / Preferences”, expanda o item “General” e clique em “Editors”, clique no link “Content-types”, selecione “Text” e digite “UTF-8” no campo “Default encoding”, pressionando o botão “Update”. Isto fará com que os caracteres acentuados apareçam de forma correta. Usuários Mac acharão o menu “Eclipse / Preferences”. Antes de começarmos, quero deixar claro que, nesta parte do livro, vamos apenas tratar de Box2D, deixando os outros assuntos para mais adiante. Eu criei um laboratório Java, porque roda em qualquer plataforma (Microsoft Windows, Linux ou Mac OSX). Vamos aprender a usar o Box2D com ele (na verdade, usando o JBox2D). Mais para o final do capítulo, eu mostrarei como usar o Box2D em projetos Android e iOS. Porém, se você não conhece Java, isto pode ser um problema. Na verdade, neste livro eu assumo que você conhece o básico de Android e de iOS, logo, tem que conhecer um pouco de Java e de Objective-C. É claro que você pode optar por usar somente uma das plataformas, ignorando a outra.
80 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
O laboratório Java O laboratório é uma aplicação bem simples, que mostra a simulação de uma bola caindo no chão e quicando.
Ilustração 33: O laboratório Box2D
Ele servirá para exercitar vários conceitos que veremos neste capítulo. O próprio Box2D tem um programa de laboratório, chamado de “TestBed”, e o JBox2D tem um programa semelhante, que pode ser acionado ao darmos um duplo clique no arquivo “jbox2d-testbed-2.1.2.2-jar-with-dependencies.jar”, que é um JAR executável, localizado na pasta “target” do projeto “JBox2D library”.
Ilustração 34: O programa Testbed
Capítulo 5 - Física de games — 81
Eu aconselho que você brinque um pouco com o Testbed, ajustando a taxa de Frames Por Segundo, o número de interações etc. Ele é excelente para estudar os recursos do Box2D (e do JBox2D), só que é meio “barra pesada” de encarar, caso você esteja começando. Então, resolvi criar um projeto laboratório bem simples, mas que isole você de outros aspectos ainda não estudados, como: Game loop e renderização, por exemplo. Ele vem com um projeto muito simples, mas que pode ser utilizado para criar outros. Para usar o projeto laboratório, basta criar uma workspace no “eclipse” e importar o projeto. Depois, você pode renomeá-lo. O laboratório é bem simples e foi implementado em Java/Swing, como uma aplicação Desktop. A classe principal (“LabMain”) é derivada de JFrame e possui uma classe derivada de JPanel “PainelGrafico” como alvo da renderização. Os métodos e classes que eu uso estão agrupados por função, precedidos de comentários. Os principais grupos são: • Iniciador da aplicação: código para instanciar a classe “LabMain” e iniciar a aplicação, exibindo a janela; • Funções que lidam com o Box2D: todas as classes e métodos que lidam diretamente com o JBox2D estão neste grupo; • Rotinas do Game Loop: os métodos e classes que se ocupam do Game loop; • Rotinas que traduzem as coordenadas e desenham: responsáveis por desenhar o estado atual do “mundo” Box2D na tela. Procure identificar estes métodos e classes no código-fonte. A simulação é iniciada com um clique do mouse sobre a janela. O evento “MouseClicked” será disparado e eu tenho uma classe derivada de MouseListener, que responde a este evento. Se a simulação estiver parada, ela vai rodá-la, caso contrário, vai pará-la. A simulação é baseada em frames, ou seja, de tempos em tempos o Game loop é executado. Aliás, vale uma observação aqui... O Game loop é um ponto muito sensível de qualquer projeto de Game, e pode ser responsável pelo seu fracasso. O jogador espera que o jogo rode suave, sem saltos e “lags”, e que rode com velocidade semelhante, seja em um Smartphone de 600 Mhz ou em um Tablet Dual de 1.0 Ghz. Se o jogo apresentar repetidamente saltos ou “lags”, pode irritar o jogador e fazer com que seu game seja detonado rapidamente. Uma boa implementação de Game loop deve ser consistente, ou seja, permitir que o jogo funcione na mesma velocidade aparente, independentemente da velocidade do processador, e também deve permitir uma renderização
82 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
suave dos frames. Mais adiante, veremos técnicas para criar Game loops otimizados, voltados para games profissionais. Porém, por enquanto, essa não é minha preocupação, logo, criei um esqueleto de Game loop bem simples, que usa a classe “java.util.Timer” para controlar sua execução. Este game loop não é tão preciso, mas funciona relativamente bem e permite estudarmos o Box2D sem grandes preocupações. Eis o código-fonte do nosso Game loop, junto com o seu “disparador”: // *** Rotinas do Game Loop *** /* * Para maior simplicidade, estamos usando as classes java. util.Timer e * java.util.TimerTask. Além disto, estamos usando renderização passiva. * Se quiser um melhor resultado, veja o capítulo “Framework básico de game”. * Este tipo de implementação de Game loop não é muito preciso, servindo apenas * para demonstração do Box2D. */ public void runGameLoop() { simulando = true; task = new GameLoopTask(); timer = new Timer(); timer.scheduleAtFixedRate(task, 0, gameLoopInterval); } public void gameLoop() { synchronized (this) { // Um lembrete de que pode haver problemas de concorrência update(); redesenhar(); }; this.pGrafico.repaint(); } class GameLoopTask extends TimerTask { @Override public void run() { gameLoop(); } }
Capítulo 5 - Física de games — 83
Eu uso um “Timer” (“java.util.Timer”) para disparar uma “Task” (classe “GameLoopTask”), que vai rodar a cada “x” milissegundos (variável “gameLoopInterval”). A minha “Task” simplesmente executa o método “gameLoop()”, que atualiza o estado do modelo (método “update()”) e comanda a criação do buffer do frame (método “redesenhar()”). Depois, ele força a atualização do painel gráfico invocando seu método “repaint()”. Eu estou usando a técnica de “Double buffering”, ou seja, eu desenho em uma imagem não atrelada à tela e depois desenho esta imagem no contexto gráfico do painel. Assim, eu evito invocar métodos de desenho complexos dentro do método “paintComponent()” do painel. private void redesenhar() { // Estamos usando a técnica de “double buffering” // Vamos desenhar em uma imagem separada gx.setColor(Color.black); gx.fillRect(0, 0, larguraImagem, alturaImagem); gx.setColor(Color.yellow); Retangulo rect = criarRetangulo(bola); gx.drawOval(Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.width)); rect = criarRetangulo(chao); gx.drawRect(Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)); }
É neste método “redesenhar()” que você deve renderizar os objetos que criou no Box2D. Neste exemplo, eu criei uma bola e um retângulo, que serve de “chão” para a bola quicar. Cada vez que o modelo for atualizado pelo Game loop, eu redesenho tudo em um buffer de imagem (variáveis “gImagem” e “gx”). Quando o painel precisa ser repintado (classe “PainelGrafico”, que foi adicionada ao JFrame), o método “paintComponent()” é invocado: class PainelGrafico extends JPanel { private static final long serialVersionUID = 5173079166655854668L; @Override protected void paintComponent(Graphics g) {
84 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS super.paintComponent(g); if (gImagem != null) { synchronized(gImagem) { g.drawImage(gImagem, 0, 0, null); } } } }
Eu simplesmente desenho a imagem que criei em meu buffer “gImagem”, a partir do canto superior da área livre do Painel. Note que o desenho é feito pelo “Thread” principal da aplicação, ou seja, aquele que está processando os métodos da classe “LabMain”, enquanto o Game loop é executado por um “Thread” separado. Quando temos múltiplos processadores (Dual) isso é uma grande vantagem. Eu sincronizei o acesso à imagem, pois pode acontecer de tentarmos acessar a imagem ao mesmo tempo que ela está sendo desenhada, o que gera imagens incompletas e “flicker” (piscar). Porém, temos um problema: ao invalidar o painel com o método “repaint()” no Game loop, estamos enviando uma mensagem para que futuramente o método “paintComponent()” do painel seja executado pelo “Thread” principal. Ou seja, é assíncrono. Pode ser que o “paintComponent()” seja executando quando estivermos no meio da gravação do buffer, o que poderia gerar resultados inesperados (Race condition). Para evitar isto, eu sincronizei a chamada o “update()” dentro do Game loop: public void gameLoop() { synchronized (gImagem) { // Um lembrete de que pode haver problemas de concorrência update(); redesenhar(); }; this.pGrafico.repaint(); }
Com isto, tenho certeza que o “paintComponent()” (invocado assincronamente) jamais pegará “sujeira” da variável “gImagem”. Com o novo Memory Model do Java, os comandos de leitura e gravação são reordenados para evitar este problema. Usando o “synchronized”, nós sinalizamos isto. Agora, vamos ver rapidamente como nós “traduzimos” as informações do mundo virtual do Box2D para a nossa tela.
Capítulo 5 - Física de games — 85
Para começar, eu criei duas áreas diferentes: • Uma janela visual (gImagem), com dimensões pré-definidas (larguraImagem e alturaImagem). As dimensões são em pixels e servem para renderização; • Uma janela “virtual”, na qual eu limito os meus objetos no mundo do Box2D. Esta janela também tem dimensões próprias (larguraMundo, alturaMundo), derivadas do tamanho real através de um fator de escala (fatorEscalaVisual); Lembre-se: para o Box2D, o “mundo” é infinito. Você tem que delimitar uma “janela” de trabalho, que possa ser ajustada futuramente à tela. O fator de escala visual que estou utilizando é 4.0, logo, os tamanhos e posições no mundo “virtual” devem ser multiplicados por 4.0 antes de renderizar. É importante notar que este fator de escala é arbitrário e pode ser mudado de acordo com o tamanho da tela real. Por que eu fiz isto? Não custa lembrar... O Box2D trabalha com metros, a tela com pixels. O Box2D tem um limite de precisão entre 0.1m e 10m, logo. Se eu criar um objeto com 10 pixels, ele será tão pequeno que, dependendo da resolução e densidade da tela, nós sequer o veremos. Então, eu uso o fator de escala para renderizar.
Normalização e ajuste para renderização Como eu mencionei, eu uso dois sistemas de coordenadas diferentes: a janela da tela e a janela “virtual”. As diferenças são em escala e na origem do eixo das ordenadas. Muitos ambientes gráficos modificam a origem do eixo das ordenadas, começando na parte superior da tela. E por que isso é um problema? Bem, quando estamos trabalhando com objetos em um plano cartesiano, nós tendemos a considerar que o eixo das ordenadas é orientado da origem “para cima”, ou seja, o “chão” fica próximo à origem. Se o sistema gráfico do dispositivo considera a origem das ordenadas “em cima”, podemos ter problemas. Por exemplo, imagine que nós criamos um círculo cujo centro fica em (3,1), e nós o consideramos como em repouso no “chão”. Na renderização, ele apareceria estar no “teto”.
86 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 35: Diferenças nas coordenadas do Box2D e da tela
Então, nós temos que ajustar a escala e a origem das ordenadas ANTES de exibir a imagem. Para isto, eu criei dois métodos que são invocados no momento da renderização: • “normalizarCoordenadas()”: obtém as coordenadas dos objetos no mundo virtual e gera novas coordenadas, para renderização, aplicando o fator de escala e invertendo a origem do eixo “y”; • “criarRetangulo()”: cria um retângulo a partir de cada objeto Box2D, devidamente normalizado, pronto para renderização. Este método invoca o “normalizarCoordenadas”; E, no momento da renderização, que ocorre no método “redesenhar()”, eu invoco “criarRetangulo()” para obter a posição e tamanho da figura que tenho que desenhar na tela. Eu tive que sincronizar o desenho para evitar o mesmo problema: tentar desenhar em uma imagem que está sendo utilizada. Eis os códigos destes métodos: private void redesenhar() { // Estamos usando a técnica de “double buffering” // Vamos desenhar em uma imagem separada synchronized(gImagem) { gx.setColor(Color.black); gx.fillRect(0, 0, larguraImagem, alturaImagem); gx.setColor(Color.yellow); Retangulo rect = criarRetangulo(bola); gx.drawOval(Math.round(rect.x), Math. round(rect.y), Math.round(rect.width),Math.round(rect. width)); rect = criarRetangulo(chao);
Capítulo 5 - Física de games — 87 gx.drawRect(Math.round(rect.x), Math.round(rect.y), Math.round(rect.width),Math.round(rect. height)); } } private Vec2 normalizarCoordenadas(Vec2 coordB2D) { Vec2 resultado = new Vec2(0.0f, 0.0f); resultado.x = coordB2D.x * fatorEscalaVisual; resultado.y = (alturaMundo coordB2D.y) * fatorEscalaVisual; return resultado; } private Retangulo criarRetangulo(Body body) { Retangulo rect = new Retangulo(); Vec2 tamanho = (Vec2) body.getUserData(); tamanho = tamanho.mul(fatorEscalaVisual); Vec2 posicao = normalizarCoordenadas(body. getPosition()); rect.x = (int) (posicao.x - tamanho.x / 2); rect.y = (int) (posicao.y - tamanho.y / 2); rect.width = (int) tamanho.x; rect.height = (int) tamanho.y; return rect; }
Fundamentos do Box2D Vamos mostrar como usar o Box2D começando pelo exemplo que já criei no laboratório. Todo o código-fonte relacionado com o Box2D está no grupo “*** Funções que lidam com o Box2D ***”. Temos o método “initBox2D()”, que inicializa o “mundo” virtual e cria os objetos que serão animados, e temos o método “update()”, que comanda a sua atualização. O método “update()” é invocado pelo Game loop e faz com que todos os objetos dinâmicos sejam atualizados. Porém, como o Box2D sabe se os objetos devem se mover e para onde devem ir? Isto é o resultado da atuação de forças sobre eles e, neste exemplo, é a força da gravidade. Classe World A classe “World” (ou “b2World”, na versão C++) é o “container” para todos os outros objetos e é através dele que tudo é calculado. Nós utilizamos
88 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
a instância de “World” para criar todos os outros objetos. Para inicializar um mundo virtual, temos que seguir a sequência: no JBox2D: World world = new World(new Vec2(0.0f, -10.0f), true);
no Box2D (C++): b2Vec2 gravidade(0.0f, -10.0f); b2World* world = new b2World(gravidade, true); ... delete world; // ao final, temos que deletar o objeto, pois C++ não tem GC!
Ao criarmos o mundo, especificamos o vetor de gravidade que afetará o nosso mundo. Utilizamos o tipo “Vec2” (C++: b2Vec2) para criar uma direção. Este tipo contém as coordenadas “x” e “y”. Estamos informando que a gravidade atuará com aceleração 10 no eixo das ordenadas, movimentando os objetos para baixo, que é um valor próximo à aceleração da gravidade real na Terra: 9,80665 m/s². E se especificarmos zero? Bem, nossos objetos dinâmicos não se moverão, exceto se alguma força for aplicada a eles. Experimente colocar: (0.3f, -10.0f) e veja a bola cair e rolar para a direita. Corpos Em um mundo Box2D, podemos ter alguns tipos de corpos: • Estáticos: JBox2d: “BodyType.STATIC”, Box2D: “b2_staticBody”; • Cinemáticos: JBox2D: “BodyType.KINEMATIC”, Box2D: “b2_kinematicBody”; • Dinâmicos: JBox2D: “BodyType.DYNAMIC”, Box2D: “b2_dynamicBody”; É muito importante entender bem a diferença entre os três tipos de corpos. Corpos estáticos: Possuem massa infinita (armazenada como zero) e não podem ser movidos como resultado da simulação. O que significa isso? Significa que eles são parecidos com o chão, ou seja, não se movem por conta de colisões ou outras forças. Mas você pode reposicioná-los, se desejar. Os corpos estáticos não reagem a colisões com outros corpos estáticos ou cinemáticos, mas apenas com corpos dinâmicos. Servem para representar o chão, teto ou paredes. Corpos cinemáticos: são semelhantes aos corpos estáticos, no sentido de que não podem sofrer efeitos de quaisquer outras forças. Porém, movem-se
Capítulo 5 - Física de games — 89
automaticamente a cada atualização, na direção da sua velocidade linear ou angular. Eles podem afetar corpos dinâmicos, mas não são afetados por outros corpos cinemáticos ou estáticos. Servem para representar elementos comuns em games, como: portas giratórias, alavancas etc. Os jogos como: Sonic (SEGA) e Super Mário (Nintendo) usam estes tipos de corpos para representar dificultades nos níveis, como plataformas móveis, por exemplo. Corpos dinâmicos: recebem efeitos de forças e sofrem alterações em seu movimento. Possuem massa finita e determinada. Podem colidir com quaisquer tipos de corpos. Para você entender bem o conceito de corpos, preparamos mais um projeto de exemplo: “CorposBox2D” (“...\Codigo\CorposBox2D\corposbox2d.zip”).
Ilustração 36: A execução do programa “CorposBox2D”
Neste programa, temos os seguintes corpos: • Chão, parede esquerda e parede direita: estáticos. São mostrados em vermelho e cercam o retângulo da cena; • Bolas: dinâmicos. São mostrados em amarelo. Uma delas está no centro e no alto, caindo em função da gravidade, e a outra está no chão, mais à direita da cena e parada; • Quadrado: estático. Um quadrado vermelho parado no canto direito, na metade da altura da tela; • Barras: cinemáticas. Duas barras verdes, uma se move da direita para a esqueda, e tem a largura maior que a altura, a outra se move de cima para baixo e tem a altura maior que a largura;
90 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Não se assuste com o tamanho do código, pois a maior parte é apenas configuração. Podemos mover toda essa parte para um arquivo XML e ler dentro do programa, como vou mostrar mais adiante. Agora, simule o programa dando um clique na tela. Rode até as duas bolas estarem no chão e observe atentamente o comportamento dos corpos. Eis minhas observações: 1 – Corpos dinâmicos colidem com corpos estáticos e cinemáticos. Você nota isso quando a bola mais à esquerda “resvala” na barra cinemática, que está se movendo na horizontal. Isto afeta o movimento da bola. Outra prova é que a bola “cai” no “chão” e quica; 2 – Corpos cinemáticos não colidem com nada. Eles simplesmente se movem, sem serem afetados por forças ou colisões. Note que a barra que se move na horizontal “atravessa” o quadrado estático. E a outra barra, que se move na vertical, “atravessa” a segunda bola, embora, no movimento de subida, ela carregue a outra bola para cima. Se esperarmos até que as duas bolas estejam no chão, os caminhos das duas barras cinemáticas vão se cruzar e elas não serão afetadas por isto; 3 – Corpos dinâmicos colidem com tudo. Conforme demonstrado pelo programa, as bolas colidem com o chão, com as barras e inclusive uma com a outra. Corpos dinâmicos são afetados por forças e por colisões; Agora, para efeito de simplicidade, vamos voltar ao exemplo inicial do laboratório (LabBox2D), de modo a explicar a criação dos corpos básicos (dinâmicos e estáticos). Criamos corpos com a seguinte sequência: 1. Criamos uma definição de corpo (JBox2D: “BodyDef”, Box2D: “b2BodyDef”); 2. Ajustamos a posição do corpo com o método “.position.set()” (Box2D: “.position.Set()”); 3. Criamos e instanciamos o corpo com o método “world.createBody()” (Box2D: “world.CreateBody()”; 4. Informamos o tipo de corpo com o método “.setType()” (Box2D: propriedade “type”); 5. Criamos uma forma de colisor (Collision Shape) e informamos o tamanho; 6. Criamos uma “fixture” para associar o corpo e o colisor; Vamos ver um exemplo de criação de corpo:
Capítulo 5 - Física de games — 91
JBox2D:
BodyDef bolaDef = new BodyDef(); bolaDef.position.set(larguraMundo / 2, alturaMundo - 5); bola = world.createBody(bolaDef); bola.setType(BodyType.DYNAMIC); Vec2 bolaTamanho = new Vec2(10.0f, 10.0f); CircleShape bolaShape = new CircleShape(); bolaShape.m_radius = 5.0f; FixtureDef bolaFixDef = new FixtureDef(); bolaFixDef.shape = bolaShape; bolaFixDef.density = 1.0f; bolaFixDef.restitution = 0.6f; bolaFixDef.friction = 0.3f; bola.createFixture(bolaFixDef); bola.resetMassData(); bola.setUserData(bolaTamanho);
Box2D (C++):
b2BodyDef bolaDef; bolaDef.position.Set(gObject.x, gObject.y); b2Body * objeto = world->CreateBody(&bolaDef); bolaDef.type = b2_dynamicBody; b2CircleShape bola; bola.m_p.Set(0, 0); bola.m_radius = gObject.altura/2; b2FixtureDef fixtureDef; fixtureDef.shape = &bola; fixtureDef.density = 4.0f; fixtureDef.friction = 0.3f; fixtureDef.restitution = 0.8f; objeto->CreateFixture(&fixtureDef); bolaDef.userData = (__bridge void *) gObject;
A forma do colisor determina a “sensibilidade” do objeto. No Box2D podemos ter vários tipos de colisores: • Circulares: JBox2D “CircleShape”, Box2D: “b2CircleShape”; • Retangulares: JBox2D “PolygonShape” com “setAsBox()”, Box2D “PolygonShape” com “SetAsBox()”; • Segmentos de reta: Box2D “b2EdgeShape”. O JBox2D não tem este tipo, mas pode ser criado; • Cadeia: Servem para ligar vários segmentos de reta. No Box2D é “b2ChainShape”. O JBox2D não tem este tipo;
92 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Como vimos no início deste capítulo, é importante que a forma colisora seja o mais próxima possível da imagem do objeto. Na verdade, podemos até combinar mais de uma forma colisora, criando objetos agrupados. Quando criamos colisores circulares, temos que informar o raio, e quando criamos colisores retangulares, temos que informar a largura e altura com o método “setAsBox()” (Box2D “SetAsBox()”. Corpos estáticos e cinemáticos são criados da mesma forma, apenas mudando o BodyType (Box2D propriedade “Type”). Veja o exemplo “CorposBox2D” para ver exemplos. O objeto “Fixture” serve para associar propriedades a um corpo, como: densidade, fricção, restituição, forma colisora, entre outras. Vamos ver algumas destas propriedades: • Densidade: a razão entre a massa e o volume do corpo. Serve para calcular a massa; • Fricção: é a resistência que um corpo apresenta, quando em contato com outro. É um valor entre 0 e 1; • Restituição: é o coeficiente de restituição do corpo, conforme vimos no início deste capítulo. É um valor entre 0 e 1, significando que um corpo com zero restituição nunca vai quicar; Depois de alterarmos a densidade de um corpo, precisamos resetar sua massa com “resetMassData()” (Box2D “ResetMassData()”); Agora, já temos elementos suficientes para entender o exemplo básico, criado no programa “LabBox2”. Criamos dois corpos: // Criamos o chao BodyDef chaoDef = new BodyDef(); // A origem do eixo das ordenadas e no canto inferior, e nao no superior chaoDef.position.set(larguraMundo / 2, 2.5f); chao = world.createBody(chaoDef); chao.setType(BodyType.STATIC); float larguraChao = larguraMundo - 2.5f; Vec2 chaoTamanho = new Vec2(larguraChao, 2.5f); // Atribuímos uma forma retangular à fixture do chão PolygonShape chaoShape = new PolygonShape(); chaoShape.setAsBox(chaoTamanho.x / 2, 1.25f); FixtureDef chaoFixDef = new FixtureDef(); chaoFixDef.shape = chaoShape; chao.createFixture(chaoFixDef); chao.setUserData(chaoTamanho);
Capítulo 5 - Física de games — 93 // Criamos uma bola BodyDef bolaDef = new BodyDef(); bolaDef.position.set(larguraMundo / 2, alturaMundo - 5); bola = world.createBody(bolaDef); bola.setType(BodyType.DYNAMIC); Vec2 bolaTamanho = new Vec2(10.0f, 10.0f); // A bola tem 10 metros de diâmetro // Atribuímos uma forma circular à fixture da bola CircleShape bolaShape = new CircleShape(); bolaShape.m_radius = 5.0f; // A bola tem 5m de raio FixtureDef bolaFixDef = new FixtureDef(); bolaFixDef.shape = bolaShape; bolaFixDef.density = 1.0f; bolaFixDef.restitution = 0.6f; bolaFixDef.friction = 0.3f; bola.createFixture(bolaFixDef); bola.resetMassData(); bola.setUserData(bolaTamanho);
No JBox2D, podemos armazenar dados externos (criados por nós, e não pelo Box2D), dentro dos objetos “Body” e “BodyDef”. Podemos associar a representação externa do nosso objeto neste campo. É para isto que serve o método “setUserData()”. E sempre que alteramos a densidade de um corpo, o que fazemos no caso da “bola”, temos que mandar o Box2D recalcular a massa, com o método “resetMassData()”. No método “update()”, nós simplesmente pedimos ao mundo virtual que avance um passo no tempo: private void update() { // Atualiza o “mundo” Box2D e o modelo de dados world.step(timeStep, velocityIterations, positionIterations); }
Força e atrito Os corpos dinâmicos são sujeitos à aplicação de forças, como a gravidade. Porém, também podemos aplicar uma força a eles, causando seu movimento. De acordo com segunda lei de Newton (http://pt.wikipedia.org/wiki/Leis_de_ Newton), a força (“F”) é o resultado da multiplicação da massa (“m”) de um corpo, por sua aceleração (“a”), que é um vetor: F = m⋅a
94 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
A unidade de força é o Newton (“N”), a unidade de Massa é o quilograma (“kg”) e a unidade de aceleração é Metros por segundo ao quadrado (“m/s2”). A aceleração ocorre em uma ou em ambas as direções (estamos em um sistema 2D). Se quisermos aplicar força a um corpo, basta multiplicar a sua massa por cada componente da aceleração. No Box2D, a classe “Body” pode receber uma força através do método “applyForce()”: • JBox2D: • quadrado.applyForce(forca, quadrado.getWorldPoint(quadrado. getLocalCenter().sub(new Vec2(20,0)))); • Box2D: • quadrado->ApplyForce(forca, quadrado->getWorldPoint (quadrado->getLocalCenter()))); Mas precisamos mesmo multiplicar a massa pela aceleração? Não podemos simplesmente colocar um valor lá? É claro que podemos! Só que teríamos que “chutar” um valor ou então calcular “por fora”. Não é mais fácil multiplicar pela massa logo de uma vez? O exemplo “ForcaSimplesBox2D” mostra bem como aplicar força a um quadrado (“...\Codigo\ForcaSimplesBox2D\forcasimplesbox2d.zip”).
Ilustração 37: O resultado de aplicação de uma força
Como vemos, uma força foi aplicada a um quadrado, por um determinado tempo. A velocidade máxima que o quadrado atingiu foi cerca de 9,89 m/s e deslizou por aproximadamente 15 m. Normalmente, calculamos em unidades
Capítulo 5 - Física de games — 95
padrão MKS (metro, quilograma e segundos). Como o Box2D fez isso? Será que está certo? Precisamos relembrar alguns conceitos envolvidos... • Força: é uma grandeza que tem a capacidade de vencer a inércia de um corpo, modificando-lhe a velocidade. É medida em Newtons (“N”). Lembrando que estamos trabalhando em um mundo 2D, logo, a força pode ser aplicada a um ou aos dois eixos; • Massa: é o produto entre a densidade e o volume de um corpo. Não deve ser confundida apenas com peso do corpo. A unidade de medida de massa é o quilograma (“kg”); • Velocidade: é a variação da posição de um objeto no espaço, com relação ao tempo, medida em metros por segundo (“m/s”); • Aceleração: é a variação da velocidade de um objeto no espaço e é vetorial, ou seja, pode ocorrer em mais de um sentido. Sua unidade de medida é metros por segundo ao quadrado (“m/s2”); • Atrito: é a força de resistência entre dois objetos. Neste caso, estamos tratando de atrito dinâmico, que ocorre quando há movimento relativo entre eles. Cada tipo de material tem um coeficiente de atrito, que deve ser combinado com o do outro para calcular a força de resistência; Nosso objeto tem massa calculada de 500 kg (“Body.getMass()”). O Box2D calcula a massa com base nas dimensões e densidade. Ele tem que extrapolar o volume do objeto e ele usa a área, afinal, em 2D os objetos não possuem volume. Ao clicar na barra de espaço, eu aplico uma força ao objeto: Vec2 forca = new Vec2(+300.0f * quadrado.getMass(), 0.0f * quadrado.getMass()); Vec2 posicao = quadrado.getWorldCenter(); quadrado.setAwake(true); origemX = quadrado.getTransform().position.x; vMax = 0; quadrado.applyForce(forca, posicao);
Antes de falarmos de “getWorldVector” e “getWorldPoint”, vamos ver como aplicamos a força. Para começar, multiplicamos 300 m/s2 pela massa do objeto e aplicamos ao eixo das abiscissas. Note que aplicamos aceleração zero ao eixo das ordenadas.
96 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Uma força tem também um sentido (um vetor), logo, devemos indicar isso ao aplicá-la. Estamos acertando bem no centro de massa do objeto, de modo a evitar “torque”, pois queremos uma aceleração linear “limpa”. O resultado é uma força de 150.000 N. Para calcular a distância que o objeto vai percorrer, eu posso aplicar a seguinte fórmula: d = V 2 / (2 . g . (f + G)) Onde: • d = distância de parada; • g = aceleração da gravidade (estamos usando 10 m/s2); • G = inclinação do plano em percentuais (zero); • V = velocidade inicial (no nosso caso, a maior velocidade obtida pela aplicação da força); • f = coeficiente de atrito entre o chão e o objeto; Não sabemos exatamente por quanto tempo a aceleração foi aplicada... É certo que foi por uma fração de segundo, pois eu uso o método “world.clearForces();” após cada atualização do mundo. Então, eu tive que obter a maior velocidade que o objeto alcançou: if (quadrado.getLinearVelocity().x > vMax) { vMax = quadrado.getLinearVelocity().x; } gx.drawString(“Velocidade X max: “ + (vMax) + “ m/s”, 30, 60);
E esta velocidade foi cerca de 9,89 m/s. Então, é fácil saber o tempo em que a força foi aplicada: v = a.t, logo: t = v/a, então: t = 9,89 / 300, ou aproximadamente: 0,0330 s. Bem, voltando ao cálculo da distância, temos que considerar o Coeficiente de Atrito Combinado dos dois objetos. Uma das maneiras de calcular é: cac = sqr(coeficiente1 * coeficiente2). O coeficiente do chão é 0.5 (“chaoFixDef.friction = 0.5f;”) e o do quadrado é 0.2 (“quadradoFixDef.friction = 0.2f;”), logo, o coeficiente de atrito combinado é 0,31622. Então, nossa fórmula de distância é: d = 9,892 / (2 . 10 . (0,31622)) = 15,47 m Logo, chegamos a um resultado aproximado com o que o Box2D calculou, e utilizamos os conceitos de força, aceleração, velocidade, massa e atrito. É claro que este é um exemplo bem simples, pois só existe um único objeto dinâmico envolvido. Se tivermos mais objetos dinâmicos, ou se tivermos aceleração nos dois eixos, teremos que considerar outros fatores, como o ângulo final do objeto. Mas, vamos devagar.
Capítulo 5 - Física de games — 97
Atenção: você não vai necessitar fazer todos esses cálculos para criar jogos! Mas deve entender os conceitos e as propriedades dos corpos, de modo a projetar movimentos mais realistas em seus Games. Agora, chegou o momento de falarmos sobre alguns métodos novos. Para começar, temos que entender o que são coordenadas locais e globais no Box2D: • Coordenadas locais: são baseadas no centro do objeto, como se ele estivesse posicionado no ponto de origem (0,0); • Coordenadas globais: são as coordenadas do mundo Box2D; Várias propriedades de objetos e parâmetros de função no Box2D exigem coordenadas locais, logo, é importante entender bem os conceitos. Alguns métodos que utilizamos neste exemplo: • “quadrado.getWordCenter()”: obtém as coordenadas do “centro de massa” do objeto, transformadas em coordenadas globais; • “quadrado.getTransform()”: obtém as transformações aplicadas ao centro do corpo, como posição e rotação. Podemos saber a posição atual com “getTransform().position”, já em coordenadas globais; • “quadrado.getMass()”: obtém a massa calculada do objeto; • “quadrado.applyForce()”: aplica uma força em um determinado ponto do mundo;
Corpos circulares Já vimos como aplicar força a corpos quadrados, agora, vamos ver como fazer isto com corpos circulares. O exemplo “ForceBox2D” (“...\Codigo\ForceBox2D\forcebox2d.zip”) mostra como isto acontece.
Ilustração 38: Força aplicada a uma bola
98 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Rode o exemplo, clique para iniciar a animação, e tecle a barra de espaço (sem manter pressionada por muito tempo). Você verá que a bola é “chutada” levemente, chegando a dar um pequeno salto. Observe a animação durante algum tempo, sem chutar novamente. Notou algo errado? Sim! Após a bola cair no chão (ou bater na parede), ela rola para sempre. Atrito de rolamento Quando um objeto circular desliza sobre um objeto plano, ocorre uma força de resistência chamada “resistência de rolagem” ou atrito de rolamento. Esta força diminui a velocidade do objeto circular, fazendo com que ele pare completamente. Repare que não é igual ao atrito dinâmico, que ocorre quando dois sólidos em contato apresentam movimento relativo entre si. Infelizmente, o Box2D ainda não calcula o atrito de rolamento, logo, os objetos circulares tendem a rolar para sempre. O que podemos fazer é “marretar” os objetos para que eles aparentem o atrito de rolamento. No método “initBox2D()”, do exemplo, há uma linha de comando comentada: //bola.setFixedRotation(true);
Retire o comentário e rode novamente. Você notará que a bola rola menos e parece estacionar depois de algum tempo. Mas o que foi exatamente que eu fiz? O método “setFixedRotation(true)” impede que o objeto role, ou seja, se submetido a uma força lateral, ele vai deslizar e NUNCA rotacionar. A bola parece estar rolando, porém, na verdade, está deslizando sobre o chão. Note que eu tive que diminuir bem os coeficientes de fricção da bola e do chão (ambos para 0,1).
Rotação dos corpos Quando temos corpos dinâmicos submetidos a forças, independentemente de sua forma (circulares, quadrados etc), temos a possibilidade de rotação, ou seja, deles rolarem sobre seus eixos de massa. O exemplo “RotationBox2D” (“...\Codigo\RotationBox2D\rotationbox2d.zip”) mostra exatamente isto.
Capítulo 5 - Física de games — 99
Ilustração 39: Um exemplo de corpo rotacionando
Para testar, rode o programa, clique com o mouse e tecle a barra de espaço (uma só vez). Você verá que o quadrado recebe um “peteleco” na parte superior esquerda, e sai rodando (no sentido horário) até parar. Os corpos dinâmicos no Box2D estão sujeitos à atuação das forças, que podem provocar ação de torque, causando a rotação do objeto. Quando isto acontece, o ângulo do objeto muda e os seus vértices reais também. Para renderizar corretamente um objeto em rotação, é necessário calcular as coordenadas reais de seus vértices.
Ilustração 40: Como obter as coordenadas dos vértices
100 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Para obter a coordenada global de um vértice (já aplicada à rotação), podemos pegar a coordenada do ponto local correspondente a ele. E é o que eu faço no novo método “criarPoligono()”: //Coordenadas do canto superior esquerdo Vec2 ponto = body.getWorldPoint( new Vec2( body.getLocalCenter().x - tamanho.x / 2, body.getLocalCenter().y + tamanho.y / 2 ) ); ponto = normalizarCoordenadas(ponto);
O método “getLocalCenter()” me dá o centro de massa em coordenadas locais. Como é um retângulo, eu posso simplesmente calcular a coordenada do vértice e transformar em coordenadas globais, através do método “getWorldPoint()”. Note que o método “getLocalCenter()”, neste caso, sempre retornará (0,0). Depois de obter as coordenadas globais no Box2D, preciso normalizá-las para a minha “janela” gráfica, como fazíamos no método “criarRetangulo()”. Por que eu não posso simplesmente desenhar um retângulo? Afinal, eu posso fazer uma transformação ao desenhá-lo em Java (Graphics2D.rotate()). A resposta é simples: sim, você pode! Na verdade, é o que faremos mais adiante. Note que eu apliquei uma força nos dois eixos e posicionei bem na metade superior do quadrado, de modo a provocar o torque desejado e a rotação no sentido horário. Vec2 forca = new Vec2(350.0f * quadrado.getMass(), 350.0f * quadrado.getMass()); Vec2 posicao = quadrado.getWorldCenter().add(new Vec2 (0,3)); quadrado.setAwake(true); origemX = quadrado.getTransform().position.x; vMax = 0; quadrado.applyForce(forca, posicao);
Desenhando corpos como polígonos Eu preciso obter as coordenadas de cada vértice do corpo, devidamente transformadas e normalizadas. Por isto, eu criei um método “criarPoligono()”,
Capítulo 5 - Física de games — 101
que faz exatamente este trabalho. Ele retorna uma instância de java.awt. Polygon. private Polygon criarPoligono(Body body) { int [] xpoint = new int [4]; int [] ypoint = new int [4]; Vec2 tamanho = (Vec2) body.getUserData(); //Coordenadas do canto superior esquerdo Vec2 ponto = body.getWorldPoint( new Vec2( body.getLocalCenter().x - tamanho.x body.getLocalCenter().y + tamanho.y ) ); ponto = normalizarCoordenadas(ponto); xpoint[0] = (int)ponto.x; ypoint[0] = (int)ponto.y; //Coordenadas do canto superior direito ponto = body.getWorldPoint( new Vec2( body.getLocalCenter().x + tamanho.x body.getLocalCenter().y + tamanho.y ) ); ponto = normalizarCoordenadas(ponto); xpoint[1] = (int)ponto.x; ypoint[1] = (int)ponto.y; //Coordenadas do canto inferior direito ponto = body.getWorldPoint( new Vec2( body.getLocalCenter().x + tamanho.x body.getLocalCenter().y - tamanho.y ) ); ponto = normalizarCoordenadas(ponto); xpoint[2] = (int)ponto.x;
/ 2, / 2
/ 2, / 2
/ 2, / 2
102 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS ypoint[2] = (int)ponto.y; //Coordenadas do canto inferior esquerdo ponto = body.getWorldPoint( new Vec2( body.getLocalCenter().x - tamanho.x / 2, body.getLocalCenter().y - tamanho.y / 2 ) ); ponto = normalizarCoordenadas(ponto); xpoint[3] = (int)ponto.x; ypoint[3] = (int)ponto.y; Polygon pol = new Polygon(xpoint, ypoint,xpoint.length); return pol; }
O método obtém o tamanho original, que eu armazenei dentro da propriedade “userData”, e calcula as coordenadas locais de cada vértice, transformando-as em globais com o método “getWorldPoint()”. Depois, eu normalizo as coordenadas (translação e escala) para a minha “janela” gráfica. Finalmente, uma instância de “java.awt.Polygon” é retornada para ser renderizada: gx.drawPolygon(criarPoligono(quadrado));
A mecânica para desenhar no Android e no iOS é um pouco diferente, mas os princípios são os mesmos.
Desenhando corpos como imagens Apesar de podermos colorir os polígonos, o resultado final não fica muito “bacana”... Em games, o visual é quase tudo! Eu diria que vale até mais do que a jogabilidade, afinal, eu vejo jogos por aí que são lindos e vendem muito, porém a jogabilidade não é tão boa. Carregue o exemplo: “...\Codigo\ImageRotation\imagerotation.zip”. Bem, ao invés de desenhar um quadrado sem graça, que mais parece com os elementos daqueles games da década de 80, vou desenhar a estátua de um “ídolo” de uma civilização antiga. Me perdoem os desenhistas, mas eu fiz o máximo que pude! Execute a classe “ImageRotationBox2d.java”.
Capítulo 5 - Física de games — 103
Ilustração 41: A mesma animação usando uma imagem
E então? Ficou legal? Ainda não? Então tente a imagem seguinte, bastando executar a classe “Cenario.java”...
Ilustração 42: Agora, com cenário de game
Agora está parecendo com um game! Bastou pegar algumas imagens do “OpenClippart” (www.openclippart.org) e criar um cenário. Bem, vamos ver como foi que eu cheguei a esse ponto. Para começar, abra a classe “ImageRotationBox2d.java”. Eu modifiquei a classe do contexto gráfico do meu buffer de imagem para “Graphics2D”,
104 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
que é compatível com o java.awt.Graphics, mas contém métodos importante para lidar com transformações. Depois, eu modifiquei um pouco o método “criarRetangulo”: private Retangulo criarRetangulo(Body body) { Retangulo rect = new Retangulo(); Vec2 tamanho = (Vec2) body.getUserData(); tamanho = tamanho.mul(fatorEscalaVisual); Vec2 posicao = normalizarCoordenadas(body.getPosition()); float angBox2d = body.getAngle() > 6.265732015 ? 0 : body. getAngle(); rect.angulo = -1 * angBox2d; rect.x = (int) (posicao.x - tamanho.x / 2); rect.y = (int) (posicao.y - tamanho.y / 2); rect.width = (int) tamanho.x; rect.height = (int) tamanho.y; return rect; }
Agora, eu obtenho também o ângulo que o corpo está, dentro da simulação do Box2D, utilizando o método “getAngle()”. Este ângulo vem em radianos (1 radiano = 180 / π graus), e tem uma consideração importante: os valores positivos são no sentido anti-horário! Então, se um objeto está em um ângulo de 0.785398163 radianos, então ele está “virado” no sentido anti-horário em 45 graus. Porém, como o sentido do eixo das ordenadas será invertido na renderização, eu devo inverter o sinal do ângulo. Se você estiver usando um ambiente gráfico “normal”, não é necessário fazer isto. Como vou desenhar uma imagem, preciso carregar o arquivo. Eu defini o ícone do ídolo como uma “BufferedImage”, e o carrego na inicialização (para evitar sobrecarga no game loop): BufferedImage idolo = null; ... // No método “initComponents()”: try { ClassLoader classLoader = this.getClass().getClassLoader(); InputStream input = classLoader.getResourceAsStream(“./ images/idolo.png”); idolo = ImageIO.read(input); }
Capítulo 5 - Física de games — 105 catch (IOException e) { JOptionPane.showMessageDialog(this, “IOException ao carregar a imagem: \r\n” + e.getMessage()); }
Bem, isto é puro Java... Eu preciso do “ClassLoader” para poder carregar um arquivo que está junto do projeto. E uso o método “read()”, da classe “ImageIO” para carregar a imagem na minha variável que vai representá-la. Agora, para desenhar o ídolo, eu usei o seguinte código (dentro do método “redesenhar()”): AffineTransform transform = gx.getTransform(); Vec2 centroImagem = normalizarCoordenadas(quadrado. getWorldCenter()); Retangulo rect = criarRetangulo(quadrado); gx.rotate(rect.angulo, centroImagem.x, centroImagem.y); gx.drawImage((Image) idolo, (int) rect.x,(int) rect.y,(int) rect.width,(int) rect.height, null); gx.setTransform(transform);
Primeiro, eu preciso saber o centro da imagem, pois eu vou rotacioná-la tendo este ponto como eixo. Isto pode ser feito com o método “getWorldCenter()”, do objeto “Body”. Só que as coordenadas precisam ser normalizadas (inverter “y” e aplicar a escala). Depois, eu preciso saber o “retângulo” do objeto, e, para isto, uso o nosso conhecido método “criarRetangulo()”. Antes de desenhar a imagem (dentro do retângulo), eu aplico uma transformação à configuração do Graphics2D, mandando rotacionar todos os desenhos de acordo com o ângulo fornecido e tendo a coordenada do centro da imagem como eixo. É importante guardar a transformação original ANTES de mandar rotacionar, caso contrário, TODOS os desenhos serão rotacionados. Note que eu guardo a configuração original na variável “transform” e, depois de desenhar a imagem, eu volto tudo como estava antes, com o método “setTransform”. O desenho da imagem é bem simples: eu uso o método “drawImage”, do objeto “Graphics2D”, informando a imagem (precisei fazer um “cast”, pois eu usei “BufferedImage”), o canto superior esquerdo, a largura e altura. O último parâmetro é uma instância de “ImageObserver”, que nós não vamos utilizar agora. E, quando você tecla a barra de espaço, eu aplico uma força nos dois eixos (aceleração de 350 m/s2), na metade superior do ídolo, fazendo-o pular girando no sentido horário.
106 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS Vec2 forca = new Vec2(350.0f * quadrado.getMass(), 350.0f * quadrado.getMass()); Vec2 posicao = quadrado.getWorldCenter().add(new Vec2 (0,3)); quadrado.setAwake(true); origemX = quadrado.getTransform().position.x; vMax = 0; quadrado.applyForce(forca, posicao);
Agora, se você gostou da segunda imagem, abra a classe “Cenario.java”. As diferenças são mínimas: • Eu não renderizo o chão e as paredes; • Eu não escrevo as informações sobre o objeto; • Eu desenho a imagem de fundo;
BufferedImage idolo = null; BufferedImage cenario = null; ... // No método “initComponents()”: try { ClassLoader classLoader = this.getClass().getClassLoader(); InputStream input = classLoader.getResourceAsStream(“./ images/idolo.png”); idolo = ImageIO.read(input); input = classLoader.getResourceAsStream(“./images/cenario. png”); cenario = ImageIO.read(input); } catch (IOException e) { JOptionPane.showMessageDialog(this, “IOException ao carregar a imagem: \r\n” + e.getMessage()); }
No início do método “redesenhar()” eu desenho a imagem de fundo: gx.drawImage((Image) alturaImagem, null);
cenario,
0,
0,
larguraImagem,
Agora, para fechar com “chave de ouro”, tenho certeza que você vai adorar este exemplo:
Capítulo 5 - Física de games — 107
Ilustração 43: Um exemplo com bola
Abra a classe “JogoDeBola.java”, clique com o mouse e tecle a barra de espaço, quantas vezes quiser. Você verá a bola quicando e rolando, como um jogo de verdade. Porém, se observar atentamente durante algum tempo, notará que a bola não para de rolar. Isto é devido à falta de atrito de rolagem, que já falamos anteriormente. Eu, particularmente, creio que isto não é um problema, mas, porém, se você quiser, pode descomentar a seguinte linha (dentro de “initBox2D”): //bola.setFixedRotation(true); // Comente esta linha para giro livre
Torque e impulso Até agora, só aplicamos forças aos corpos, porém, há outras maneiras de alterar o seu momento (http://pt.wikipedia.org/wiki/Torque), como o impulso e o torque. O impulso mede a variação da quantidade de movimento de um corpo. É o resultado da aplicação de uma força em um intervalo de tempo. Se a força aplicada é constante durante todo o tempo, o cálculo é simples: I = F.Δt, porém, se a força é variável, o cálculo é bem mais complexo. No Box2D, aplicamos impluso com os métodos “applyLinearImpulse()” e “applyAngularImpulse()”. O primeiro aplica um impulso linear em um ou nos dois eixos, se aplicado ao centro de massa do corpo, e o segundo, aplica um impulso angular, que provoca a rotação do objeto.
108 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
O torque é um movimento de alavanca em um objeto, fazendo-o girar sobre seu eixo. Na verdade, é muito parecido com a aplicação de impulso angular. Tenho mais um exemplo sobre isso: “ImpulseTorque” (“...\Codigo\ImpulseTorque\impulsetorque.zip”).
Ilustração 44: Comparação entre: impulso, força e torque
Neste exemplo, vemos quatro quadrados, da esquerda para a direita: 1. Amarelo: é submetido a uma força com aceleração de 350 m/s2 apenas no sentido positivo das ordenadas; 2. Verde: é submetido a um impluso também no eixo “y”. Aplicamos um impulso com aceleração de 350 m/s2, por 1 segundo; 3. Azul claro: levantamos o quadrado, aplicando um pequeno impulso linear e depois aplicamos o torque, no sentido horário; 4. Magenta: levantamos o quadrado, aplicando um impulso angular no sentido anti-horário; No Box2D, uma força é aplicada e a velocidade do objeto vai aumentando gradativamente. Quando aplicamos um impulso, a velocidade é modificada instantaneamente! É como se tivéssemos aplicado uma força muito maior ao objeto. A mesma comparação existe entre o torque e o impulso angular. Quando aplicamos um impulso linear, a unidade que o Box2D espera é N/s (Newtons por segundo). O código que modifica o momento dos quadrados está no código que intercepta a tecla de espaço:
Capítulo 5 - Física de games — 109 public void keyPressed(KeyEvent key) { if (key.getKeyCode() == KeyEvent.VK_SPACE) { // Quadrado 1: Força Vec2 forca = new Vec2(0, 350.0f * quadrado1.getMass()); Vec2 posicao = quadrado1.getWorldCenter(); quadrado1.setAwake(true); quadrado1.applyForce(forca, posicao); // Quadrado 2: Impulso Vec2 forca2 = new Vec2(0, 350.0f * quadrado2.getMass()); Vec2 posicao2 = quadrado2.getWorldCenter(); quadrado2.setAwake(true); quadrado2.applyLinearImpulse(forca2, posicao2); // Quadrado 3: Torque /* primeiro levantamos o quadrado e depois aplicamos o torque */ Vec2 forca3 = new Vec2(0, 30.0f * quadrado3.getMass()); Vec2 posicao3 = quadrado3.getWorldCenter(); quadrado3.setAwake(true); quadrado3.applyLinearImpulse(forca3, posicao3); quadrado3.setAwake(true); quadrado3.applyTorque(-5000.0f); // Quadrado 4: Impulso angular /* primeiro levantamos o quadrado e depois aplicamos o impulso */ Vec2 forca4 = new Vec2(0, 33.0f * quadrado4.getMass()); Vec2 posicao4 = quadrado4.getWorldCenter();
110 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS quadrado4.setAwake(true); quadrado4.applyLinearImpulse(forca4, posicao4); quadrado4.setAwake(true); quadrado4.applyAngularImpulse(550.0f); } }
Deteção de colisão Ok, já sabemos movimentar bem os objetos, porém, como saber quando eles colidiram? Afinal, muitos games de ação dependem desta informação para acumular pontos, destruindo inimigos, ou mesmo para saber quando o herói foi atingido. No Box2D podemos saber isto criando uma classe que implementa a interface “ContactListener” (C++: “b2ContactListener”). Esta interface possui alguns métodos de “Callback” que podemos utilizar para saber informações sobre a colisão: • Begin Contact: dois corpos começaram a se sobrepor. Esta rotina só pode ser invocada de dentro do loop do Box2D (Step); • End Contact: dois corpos deixaram de ter contato. Esta rotina pode ser chamada de fora do loop do Box2D. Por exemplo, você removeu um dos objetos que estava sobre o chão; • Pre-solve: esta chamada acontece depois que houve a colisão, porém antes de sua resolução, ou seja, do reposicionamento dos objetos; • Post-solve: acontece depois que houve a colisão e ela foi processada. O importante é que o Box2D não deixa você fazer certas operações enquanto estiver dentro do processamento do método “step()”. Por exemplo, você poderia destruir um objeto, causando um problema para o processamento interno dele. A maneira que o Box2D recomenda é você armazenar todos os dados de colisão que lhe interessam e processá-los após a execução do método “step()”. O exemplo “ContactBox2D” (“...\Codigo\ContactBox2D\contactbox2d. zip”) mostra um bom exemplo de deteção de colisão.
Capítulo 5 - Física de games — 111
Ilustração 45: Deteção de colisão
Ao rodar a simulação, você verá que a bola rola até atingir o quadrado, então a simulação é interrompida e uma mensagem aparece. Este seria o comportamento típico quando o Player “morreu” e acabaram as vidas... É claro que você pode fazer diferente, por exemplo: incrementar (ou decrementar) os pontos, vidas etc. Eu tive que modificar um pouco o que eu coloco dentro da propriedade “userData” de cada corpo. Eu criei uma classe que armazena o tamanho (que eu uso para calcular o retângulo) e um “id” de cada corpo. Assim, posso saber quais corpos colidiram. DadosCorpo dadosBola = new DadosCorpo(bolaTamanho,5); ... bola.setUserData(dadosBola); ... class DadosCorpo { public Vec2 tamanho; public int id; public DadosCorpo() { super(); } public DadosCorpo(Vec2 tamanho, int id) { this(); this.tamanho = tamanho;
112 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS } }
this.id = id;
Depois, eu criei uma classe que implementa a interface “ContactListener”: class Contato implements ContactListener {
@Override public void beginContact(org.jbox2d.dynamics.contacts. Contact c) { DadosCorpo corpoA = (DadosCorpo) c.getFixtureA(). getBody().getUserData(); DadosCorpo corpoB = (DadosCorpo) c.getFixtureB(). getBody().getUserData(); if (corpoA.id >=4 && corpoB.id >= 4) { atingiu = true; } } @Override public void endContact(org.jbox2d.dynamics.contacts. Contact arg0) { } @Override public void postSolve(org.jbox2d.dynamics.contacts. Contact arg0, ContactImpulse arg1) { } @Override public void preSolve(org.jbox2d.dynamics.contacts. Contact arg0, Manifold arg1) { } }
A instância de “Contact” que eu recebo contém informações sobre os dois corpos que entraram em contato. Posso usar os métodos “getFixtureA()”
Capítulo 5 - Física de games — 113
e “getFixtureB()”, para obter os dois corpos, e posso usar as propriedades de suas instâncias de “userData” para saber o “id” de cada um. Como estes “Callbacks” são invocados para TODOS os objetos e TODOS os tipos de contato, eu preciso saber quais foram os corpos que colidiram. No meu caso, o “id” da bola é “5” e o do quadrado é “4”, logo, se os dois ids forem maiores ou iguais a “4”, então a colisão é entre a bola e o quadrado. Para concluir, tenho que adicionar uma instância do meu “ContactListener” ao mundo do Box2D (ao final do método “initBox2D()”): // Adiciona o contact listener Contato c = new Contato(); world.setContactListener(c);
Quando a bola e o quadrado colidem, eu ligo o flag “atingiu”, e no método “update()” eu testo isto logo após invocar o método “step()”: private void update() { // Atualiza o “mundo” Box2D e o modelo de dados world.step(timeStep, velocityIterations, positionIterations); world.clearForces(); if (atingiu) { setTitle(tituloParado); timer.cancel(); task = null; simulando = false; JOptionPane.showMessageDialog(this, “A bola atingiu o quadrado”); } }
E assim eu posso processar as colisões. É importante saber que os métodos de “Callback” serão invocados sempre que as colisões forem possíveis: • Dinâmico / dinâmico; • Dinâmico / estático; • Dinâmico / cinético.
Juntas ou junções Em física mecânica, temos o conceito de “graus de liberdade”, que é o número de parâmetros independentes para obter a posição (estado) de um corpo. Em um plano (espaço bidimensional), temos três graus de liberdade: deslocamento horizontal (abscissas), vertical (ordenadas) e rotação (em um eixo perpendicular ao plano).
114 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Uma junta (ou junção) é uma conexão entre dois corpos, que limita seus graus de liberdade. Por exemplo, se pregarmos um retângulo em um quadro de avisos, com um alfinete bem no centro, ele poderá girar em torno do alfinete, mas não poderá se mover. Existem vários tipos de juntas: • Rotacional; • Prismática • Cilíndrica; • Esférica; • Engrenagem; Mas vamos nos concentrar nos tipos de juntas que o Box2D permite criar, usando a sua própria nomenclatura: • Distance joint (junção de distância): interliga dois corpos, fazendo com que a distância entre eles seja sempre constante. Exemplo: dois corpos ligados por um cano; • Revolute joint (junção rotacional): quando dois corpos compartilham um ponto em comum, podendo girar um em relação ao outro, em torno do ponto comum. Exemplo: sobrepomos dois retângulos de papel (não totalmente) e os atravessamos com um alfinete; • Prismatic joint (junção prismática): os corpos estão unidos, mas não podem girar um com relação ao outro, porém, podem deslizar sobre a junta. É difícil pensar em um exemplo elucidativo, mas vamos tentar... Imagine atravessar dois pedaços de madeira com uma barra quadrada, mas que deixe folga para que os pedaços de madeira deslizem sobre a barra, embora não possam girar; • Pulley joint (polia): os corpos estão ligados a uma polia (ou roldana). Exemplo: imagine um varal de roupas de prender no teto. Ao afrouxarmos uma das cordas, a parte do varal ligada a ela desce, e ao puxarmos, a referida parte sobe; • Gear joint (engrenagem): liga duas juntas (prismáticas ou rotacionais), permitindo que o movimento de uma afete o da outra. Exemplo: um círculo com junta rotacional ligado a um retângulo com junta prismática (ligada a um plano); • Mouse joint: conecta um ponto em um corpo a um ponto no mundo Box2D, geralmente, determinado pelo clique do Mouse; • Wheel joint (ex Line Joint): conecta um ponto de um corpo a uma linha de outro corpo. Exemplo: a suspensão de um carro. A roda gira e também se move para cima e para baixo, na linha de suspensão;
Capítulo 5 - Física de games — 115
• Weld joint: tenta manter os corpos ligados em uma única posição, porém, pode ser “quebrada” ou amassada. Exemplo: uma parede com tijolos; • Rope joint (corda): é semelhante à “distance joint”, só que permite variar (para menos) a distância entre os dois corpos. Ela estabelece uma distância máxima. É como se os dois corpos fossem ligados por uma corda; • Friction joint: possui parâmetros para limitar o movimento relativo dos dois corpos ligados; Bem, o tipo de junção que você vai usar dependerá do tipo dos Game Objects envolvidos. Para começar, vou mostrar uma demonstração simples. Abra o exemplo: “SimpleJoint” (“...\Codigo\SimpleJoint\simplejoint.zip”).
Ilustração 46: Exemplo de junção
Rode o exemplo, clique com o mouse para iniciar a simulação. Você verá que os corpos caem juntos, como se estivessem ligados por algo rígido, como um cano, porém, que permite rotação nas pontas. Clique algumas vezes com a barra de espaço e veja os objetos pularem e rodarem em torno dos seus pontos de acoragem (o centro de massa). Há poucas diferenças entre este código e os anteriores. Para começar, criamos uma junta entre os dois corpos, especificando também o ponto de ancoragem (onde a junta se prende) entre eles:
116 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS // *** Criamos a junção entre os dois corpos: DistanceJointDef jointDef = new DistanceJointDef(); jointDef.initialize(quadrado, bola, quadrado.getWorldCenter(), bola.getWorldCenter()); jointDef.collideConnected = true; juncao = (DistanceJoint) world.createJoint(jointDef);
Criamos um objeto “DistanceJointDef” para especificar os parâmetros da junção, depois, usamos o método “initialize” para informar quais são os dois objetos conectados e qual é o ponto de ancoragem em cada um deles. Eu usei o centro de massa, em coordenadas globais, como ponto de ancoragem. Depois, simplesmente mandamos criar uma junção com base na definição. Note que eu mudei a propriedade “collideConnected” para “true”. Isto não tem o menor efeito neste exemplo, já que estou usando uma ligação rígida e são apenas dois corpos. Porém, se houvesse mais corpos (e mais juntas) ou se eu usasse uma “Rope joint”, isto permitiria que os corpos interligados colidissem. E como vou desenhar a junção? Simples: // Desenha a junção: gx.setColor(Color.magenta); Vec2 cdCorpoA = normalizarCoordenadas(juncao.getBodyA(). getWorldCenter()); Vec2 cdCorpoB = normalizarCoordenadas(juncao.getBodyB(). getWorldCenter()); gx.drawLine((int)cdCorpoA.x, (int)cdCorpoA.y, (int) cdCorpoB.x, (int)cdCorpoB.y);
Eu desenho uma linha entre os centros dos dois corpos, devidamente normalizados para a minha janela gráfica. Ao teclar barra de espaço, eu aplico uma força ao quadrado: Vec2 forca = new Vec2(1000.0f, 1000.0f * quadrado.getMass()); Vec2 posicao = quadrado.getWorldCenter(); quadrado.setAwake(true); quadrado.applyForce(forca, posicao);
Capítulo 5 - Física de games — 117
Múltiplos objetos interligados Agora, vamos ver um sistema de objetos ligados por juntas. Abra o exemplo “WormJoint” (“...\Codigo\WormJoint\wormjoint.zip”).
Ilustração 47: Um exemplo interessante de uso de junções
Rode o exemplo e veja uma “minhoca” composta por 10 bolas interligadas cair no chão. Elas caem todas juntas. Porém, tecle a barra de espaço algumas vezes e veja a “minhoca” dançar e pular. É bem legal não? Nós fizemos algumas mudanças no exemplo “SimpleJoint”. Para começar, criamos as bolas e as juntas usando loops: // Vamos criar a cadeia de bolas bolas = new Body[numBolas]; for (int ix = 0; ix < numBolas; ix++) { BodyDef bolaDef = new BodyDef(); bolaDef.position.set((larguraMundo / (numBolas + 1)) * (ix+1) , 80); Body bola = world.createBody(bolaDef); bola.setType(BodyType.DYNAMIC); Vec2 bolaTamanho = new Vec2(2.0f, 2.0f); CircleShape bolaShape = new CircleShape(); bolaShape.m_radius = 1.0f; FixtureDef bolaFixDef = new FixtureDef();
118 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS bolaFixDef.shape = bolaShape; bolaFixDef.density = 1.0f; bolaFixDef.restitution = 0.6f; bolaFixDef.friction = 0.1f; bola.createFixture(bolaFixDef); bola.resetMassData(); bola.setUserData(bolaTamanho); bolas[ix] = bola; } // *** Criamos a junção entre cada par de bolas: for (int ix = 0; ix < (numBolas - 1); ix++) { DistanceJointDef jointDef = new DistanceJointDef(); jointDef.initialize(bolas[ix], bolas[ix+1], bolas[ix]. getWorldCenter(), bolas[ix+1].getWorldCenter()); jointDef.collideConnected = true; DistanceJoint juncao = (DistanceJoint) world. createJoint(jointDef); }
E para renderizar também usamos loops: // Desenha as bolas: gx.setColor(Color.cyan); for (int ix = 0; ix < numBolas; ix++) { Retangulo rect = criarRetangulo(bolas[ix]); gx.drawOval(Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.width)); } // Desenha as junções: gx.setColor(Color.yellow); for (int ix = 0; ix < (numBolas - 1); ix++) { Vec2 cdCorpoA = normalizarCoordenadas(bolas[ix]. getWorldCenter()); Vec2 cdCorpoB = normalizarCoordenadas(bolas[ix+1]. getWorldCenter()); gx.drawLine((int)cdCorpoA.x, (int)cdCorpoA.y, (int) cdCorpoB.x,
Capítulo 5 - Física de games — 119 }
(int)cdCorpoB.y);
E, ao teclar a barra de espaço, eu escolho aleatoriamente em qual das bolas será aplicada a força: int num = random.nextInt(9); Vec2 forca = new Vec2(2000.0f, 2000.0f * bolas[num].getMass()); Vec2 posicao = bolas[num].getWorldCenter(); bolas[num].setAwake(true); bolas[num].applyForce(forca, posicao);
Assim, criamos um efeito muito legal, parecido com o que acontece no jogo “World of Goo”, por exemplo (www.worldofgoo.com). Simulando destruição Bem, as junções são muito interessantes. Os efeitos que podemos criar são incríveis mesmo, por exemplo, podemos simular a destruição de um objeto, como uma parede. Podemos destruir um objeto de várias maneiras: 1. Separando as fixtures: criamos um objeto com múltiplas “fixtures” e, ao ser atingido, separamos as fixtures e colocamos em outros objetos, para simular fragmentos; 2. Separando os objetos: criamos vários objetos agregados (pode ser por juntas) e, ao ser atingido, nós os separamos; Eu optei pela segunda abordagem neste exemplo: “WeldJoint” (“...\Codigo\ WeldJoint\weldjoint.zip”).
Ilustração 48: Simulando a quebra de uma parede
120 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Eu criei um ambiente com alguns objetos: • Bola: dinâmico, com densidade 5; • Rampa: estático, um triângilo com shape poligonal – os vértices tem que ser no sentido horário, pois estamos lidando com “y” invertido; • Quadrado: dinâmico. Seis deles unidos por Weld joints; Ao ser acionada, a bola avança, sobe pela rampa e colide com a parede, quebrando-a completamente. Rode o exemplo, clique com o mouse e tecle barra de espaço. Neste exemplo, eu tive que detetar colisões, logo, peguei a mesma classe do exemplo “ContactBox2D” para isto: class Contato implements ContactListener { @Override public void beginContact(org.jbox2d.dynamics. contacts.Contact c) { DadosCorpo corpoA = (DadosCorpo) c.getFixtureA().getBody(). getUserData(); DadosCorpo corpoB = (DadosCorpo) c.getFixtureB().getBody(). getUserData(); if (corpoA.id >=4 && corpoB.id >= 4) { atingiu = true; if (corpoA.id >=5) { tijoloAtingido = tijolos[corpoA.id - 5]; } else if (corpoB.id >= 5) { tijoloAtingido = tijolos[corpoB.id - 5]; } } }
Eu armazeno no “userData” de cada corpo uma instância de classe minha (“DadosCorpo”), que tem o tamanho e um identificador. Os tijolos são criados todos com identificação a partir de 5 (5,6,7...). Então, para saber qual é o índice do tijolo, é só subtrair 5 do identificador. Então, eu guardo o tijolo que foi atingido, de modo a “quebrar” suas junções. Eu tenho que adicionar o meu “Listener” ao mundo Box2D: // Adiciona o contact listener Contato c = new Contato(); world.setContactListener(c);
Capítulo 5 - Física de games — 121
Eu crio os tijolos e os outros objetos da mesma forma que venho fazendo. A criação da parede é muito semelhante à criação do “verme” do exemplo anterior: // Vamos criar a cadeia de tijolos tijolos = new Body[numTijolos]; for (int ix = 0; ix < numTijolos; ix++) { BodyDef tijoloDef = new BodyDef(); tijoloDef.position.set((larguraMundo / 5) * 4, 8 * (ix) + 5.0f); Body tijolo = world.createBody(tijoloDef); tijolo.setType(BodyType.DYNAMIC); Vec2 tijoloTamanho = new Vec2(5.0f, 5.0f); FixtureDef tijoloFixDef = new FixtureDef(); PolygonShape tijoloshape = new PolygonShape(); tijoloshape.setAsBox(2.5f, 2.5f); tijoloFixDef.shape = tijoloshape; tijoloFixDef.density = 4.0f; tijoloFixDef.restitution = 0.6f; tijoloFixDef.friction = 0.1f; tijolo.createFixture(tijoloFixDef); tijolo.resetMassData(); DadosCorpo tijoloCorpo = new DadosCorpo(tijoloTamanho,5 + ix); tijolo.setUserData(tijoloCorpo); tijolos[ix] = tijolo; } // *** Criamos a junção entre cada par de tijolos: for (int ix = 0; ix < (numTijolos - 1); ix++) { WeldJointDef jointDef = new WeldJointDef(); jointDef.initialize(tijolos[ix], tijolos[ix+1], tijolos[ix]. getWorldCenter()); jointDef.collideConnected = true; world.createJoint(jointDef); }
122 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Porém, na hora de renderizar a parede, eu preciso saber quais juntas ainda existem: // Desenha as junções: gx.setColor(Color.yellow); for (int ix = 0; ix < (numTijolos - 1); ix++) { JointEdge je = tijolos[ix].getJointList(); while (je != null) { Vec2 cdCorpoA = normalizarCoordenadas(tijolos[ix]. getWorldCenter()); Vec2 cdCorpoB = normalizarCoordenadas(je.other. getWorldCenter()); gx.drawLine((int)cdCorpoA.x, (int)cdCorpoA.y, (int)cdCorpoB.x, (int)cdCorpoB.y); je = je.next; } }
A classe “Body” tem o método “getJointList()”, que retorna uma lista encadeada (não é uma estrutura Java!) apontando para a lista de junções. A variável “next” indica a próxima junção do objeto. O método “getJointList()” retorna uma instância de “JointEdge”, que é um elemento desta lista encadeada. Cada “JointEdge” possui vários ponteiros, incluindo o “other”, que é para o outro corpo da junção. Resumindo, eu só desenho as linhas de junção enquanto elas existirem. E, para fechar, quando a bola atinge um quadrado, eu guardo qual foi o atingido. Depois, no método “update()”, eu removo uma de suas junções: if (atingiu) { if (tijoloAtingido != null) { Joint junta = tijoloAtingido.getJointList() == null ? Null : tijoloAtingido.getJointList().joint; if (junta !=null) { world.destroyJoint(tijoloAtingido. getJointList().joint); } } atingiu = false; }
Capítulo 5 - Física de games — 123
Note que pode ocorrer colisão várias vezes, então, eu tenho que testar se ainda existem junções naquele objeto. Se você rodar a simulação várias vezes, verá que a parede vai se quebrando aos poucos, com alguns segmentos ainda inteiros. Isto é devido ao fato de eu ter usado Weld joint. Senão, ela ficaria “molenga”, como o exemplo do verme. Se eu não tivesse removido as juntas na colisão, a parede se dobraria e inclinaria, mas os tijolos continuariam juntos.
Usando o Box2D em plataformas móveis O objetivo deste livro é fornecer um conjunto de ferramentas para desenvolvimento de games em plataformas móveis: Android e iOS. Então, faz todo sentido vermos como desenvolver protótipos com o Box2D nestas plataformas.
Box2D no Android Vamos eleger o Android como nossa primeira plataforma, para começar. Em primeiro lugar, quase tudo que fizemos em Java serve, incluindo o “Game Loop”. Ok. Vamos escolher o nosso exemplo “ImageRotation”, cuja classe “JogoDeBola” simula uma bola sendo chutada em um cenário com chão e duas paredes. Eis como o jogo deverá rodar no Android:
Ilustração 49: O jogo rodando no emulador Android
124 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Antes de mais nada, deixe-me esclarecer que, mesmo sendo feito em Java, o jogo não pode rodar diretamente no Android. A API de interface de usuário (GUI) é completamente diferente do Javax / Swing, logo, não dá nem para pensar em rodar o jogo como está. Vamos ter que adaptá-lo para funcionar como uma aplicação Android. O código-fonte do exemplo está em: “...\Codigo\Android\exemplobox2d.zip”. Neste livro, eu pretendo me concentrar apenas nas ferramentas e técnicas para desenvolvimento de games para Android e iOS, logo, não faz parte do escopo ensinar a desenvolver aplicações para estes dispositivos. Se você não conhece programação para Android, eu recomendo duas fontes que podem te ajudar: 1. Meu outro livro: “Mobile Game Jam” (www.mobilegamejam.com) que possui uma boa introdução à programação Android; 2. O curso de programação Android do meu portal: “The Code Bakers” (http://www.thecodebakers.org); Para começar, crie um projeto Android. Você pode utilizar o target que desejar, mas cuidado para não usar características mais avançadas, pois a maioria dos dispositivos ainda é Android 2.3.3 (API 10). Nós vamos desenhar diretamente na tela, e queremos que a aplicação rode em “tela cheia”, na orientação “Landscape” (deitada). Então, para começar, altere a definição da sua “Activity”, dentro do “AndroidManifest.xml”, para acrescentar o atributo “android:screenOrientation”:
Isto determina a orientação da sua “Activity” na tela e “landscape” significa “Paisagem”, ou orientação “deitada”. E também devemos impedir que o dispositivo pare a “Activity” ao ser rotacionado. Primeiramente, informamos o atributo “android:configChanges”, que informa ao Android que nossa “Activity” quer ser informada caso o dispositivo mude sua orientação. E temos que sobrescrever o método “onConfigurationChanged()”: @Override public void onConfigurationChanged(Configuration newConfig) { newConfig.orientation = Configuration.ORIENTATION_LANDSCAPE; super.onConfigurationChanged(newConfig); }
Capítulo 5 - Física de games — 125
Se houver mudança de configuração de orientação, nós forçamos nossa tela a ficar em “Landscape”. Agora, vamos pensar um pouco: nós vamos desenhar diretamente na tela, o que no Android significa: “View”. Então, vamos criar uma classe derivada de “View”. Esta classe implementa o método “onDraw()”, que é invocado sempre que a “View” precisa ser regenerada (redesenhada). Então, parece ser um bom lugar para desenharmos nossos objetos: class GameView extends View { public GameView(Context context) { super(context); } @Override protected void onDraw(Canvas canvas) { redesenhar(canvas); } }
Uma “View” precisa ser redesenhada sempre que for considerada inválida, o que pode acontecer devido a vários motivos. E nós vamos invalidar nossa “View” após cada atualização do mundo Box2D. Agora, precisamos informar à nossa “Activity” que uma instância de “GameView” será o conteúdo a ser apresentado na tela. Fazemos isto no método “callback” “onCreate()”: @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); gView = new GameView (this.getApplicationContext()); gView.setOnTouchListener(this); this.requestWindowFeature(Window.FEATURE_NO_TITLE); this.getWindow().setFlags(WindowManager.LayoutParams. FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); setContentView(gView); initComponents(); }
O que interessa é o código em negrito. Estamos instanciando a nossa classe “GameView”, passando para ela o contexto de aplicação do Android. Depois,
126 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
precisamos detectar quando a GameView foi tocada, logo, nós passamos a instância da Activity para ela (a Activity precisa implementar “OnTouchListener”. Depois, nós desabilitamos o título da janela e a marcamos como “Fullscreen”, logo, nem aquela barra com sinal, hora e bateria aparecerá. Finalmente, usamos o método “setContentView()” para informa à Activity qual instância de “View” fornecerá o conteúdo para a tela. Normalmente, nós usamos uma view definida em XML (Layout), mas não é o nosso caso. Para terminar, chamamos o nosso conhecido método “initComponents()”, que inicializa os principais componentes da animação: private void initComponents() { DisplayMetrics displayMetrics = new DisplayMetrics(); WindowManager wm = (WindowManager) getApplicationContext().getSystemService(Context. WINDOW_SERVICE); wm.getDefaultDisplay().getMetrics(displayMetrics); int screenWidth = displayMetrics.widthPixels; int screenHeight = displayMetrics.heightPixels; alturaImagem = screenHeight; larguraImagem = screenWidth; int raioReal = Math.round((larguraImagem * bolaPercentScreen) /100); fatorEscalaVisual = raioReal; Options opc = new Options(); opc.inDither = true; opc.inScaled = false; opc.inPreferredConfig = Bitmap.Config.ARGB_8888; fundo = BitmapFactory.decodeResource(getResources(), R.drawable. cenario2, opc); fundo = Bitmap.createScaledBitmap(fundo, larguraImagem, alturaImagem, true); imagemBola = BitmapFactory.decodeResource(getResources(), R.drawable.bola, opc); imagemBola = Bitmap.createScaledBitmap(imagemBola, (int)(10 * fatorEscalaVisual), (int)(10 * fatorEscalaVisual), true); paint = new Paint(); alturaMundo = alturaImagem / fatorEscalaVisual; larguraMundo = larguraImagem / fatorEscalaVisual; initBox2D(); }
Capítulo 5 - Física de games — 127
Calculando o tamanho proporcional dos Game Objects Eu inicializei a altura e largura da imagem a partir do tamanho real da tela do dispositivo. Como estamos desenvolvendo para um dispositivo móvel real, não podemos assumir que a altura e largura da imagem serão fixas, então, usamos a classe “DisplayMetrics” para obter a altura e largura da janela do dispositivo em pixels. E também calculamos o fator de escala visual de maneira relativa ao tamanho da tela. No nosso caso, eu estipulei um percentual da tela para ser o tamanho do Raio da bola. Assim, a bola aparecerá com um tamanho proporcional em de acordo com o tamanho de cada dispositivo. Caso contrário, ela ficaria sempre do mesmo tamanho. Então, eu calculo as dimensões do mundo virtual (usando o Fator de escala visual). Também carregamos e reescalamos as duas imagens (fundo e bola), que devem estar na pasta “res/drawable/” (no meu caso: “res/ drawable/mdpi”). Todos os objetos que serão utilizados no desenho, no método “onDraw()”, devem ser inicializados previamente. Assim, inicializamos os bitmaps e a configuração de pintura (Paint) que vamos usar. Preparando os bitmaps antecipadamente Eu pretendo carregar dois bitmaps: um para o fundo e outro para a bola. E pretendo reescalar estes bitmaps de acordo com o tamanho físico da tela do dispositivo. Para evitar problemas, eu preciso dar um tratamento especial ao carregá-los. A classe “BitmapFactory.Options” permite especificar algumas opções para a carga das imagens: • “opc.inDither = true” : acrescenta um “ruído” intencional à imagem, evitando erros na conversão analógica/digital, como faixas de cor, por exemplo. É importante em imagens que serão reescaladas pela aplicação; • “opc.inScaled = false” : se quisermos alterar a densidade (dpi) da imagem ao carregá-la. Neste caso, desligamos esta opção para não alterá-la; • “opc.inPreferredConfig = Bitmap.Config.ARGB_8888” : ele vai tentar decodificar a imagem utilizando 4 bytes por pixel: RGB + alpha (transparência); Nós usamos o método “createScaledBitmap”, da classe “android.graphics. Bitmap”, de modo a criar uma versão das imagens no tamanho que desejamos. Isto evita “lags” na renderização, pois a operação de reescalar imagens consome muito tempo.
128 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Para terminar, chamamos o nosso conhecido método “initBox2D()”, que é EXATAMENTE o mesmo do exemplo “ImageRotation” da versão Javax / Swing. Porém, esta simulação é comandada de modo diferente da versão Javax / Swing. Nós temos que teclar “MENU” para iniciar ou parar a simulação e temos que tocar na tela (não é deslizar o dedo, só tocar) para aplicar a força. Para isto, tivemos que intercetar o método “onKeyUp()” da classe “Activity”: @Override public boolean onKeyUp(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_MENU) { if (!simulando) { if (world != null) { initBox2D(); } simulando = true; runGameLoop(); } else { simulando = false; gView.invalidate(); timer.cancel(); task = null; } return true; } else if (keyCode == KeyEvent.KEYCODE_BACK) { Intent intent = new Intent(Intent.ACTION_ MAIN); intent.addCategory(Intent.CATEGORY_HOME); startActivity(intent); } }
return false;
Ao pressionar e soltar qualquer tecla do Android, o processamento cairá neste “callback”. Se a tecla “MENU” foi pressionada, nós iniciamos ou paramos a simulação e se a tecla “BACK” foi pressionada, nós voltamos para a Activity “HOME” do Android.
Capítulo 5 - Física de games — 129
Para saber se a GameView foi tocada pelo usuário, nós usamos o método “onTouch()”, da interface “OnTouchListener”, que nossa Activity implementa. Para usar este recurso, tivemos que habilitar nossa Activity como “OnTouchListener” da GameView, o que fizemos no “onCreate()”. Veja como intercetamos o toque na tela: @Override public boolean onTouch(View arg0, MotionEvent arg1) { // Comanda a aplicação de forças Vec2 forca = new Vec2(200.0f * bola.getMass(), 200.0f * bola. getMass()); Vec2 posicao = bola.getWorldCenter().add(new Vec2 (0,3)); bola.setAwake(true); bola.applyForce(forca, posicao); return true; }
O conteúdo do método é praticamente o mesmo utilizado na versão Javax / Swing. O Game Loop é o mesmo, exceto por uma pequena diferença no método que é invocado pelo Thread:
public void gameLoop() { synchronized (this) { // Um lembrete de que pode haver problemas de concorrência update(); }; this.gView.postInvalidate(); }
Ao invés de chamar o método “redesenhar()”, nós mandamos nossa GameView se autoinvalidar. Nós não usamos a técnica de “Double buffering” porque resultaria em “flicker” (piscadas) na execução do game. Se você quiser, pode usar uma instância de “SurfaceView”, ao invés de “View” como sua GameView. E, para evitar problemas de concorrência, nós não podemos simplesmente invalidar a view, pois este código está sendo executado por um Thread diferente do principal e não pode ter acesso às estruturas de dados da
130 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
interface de usuário. O “postInvalidade()” enfilera um pedido para invalidar a view, que será executado pelo Thread principal. O desenho da tela é feito no método “redesenhar()”, que é invocado pelo “onDraw()” da nossa GameView: private void redesenhar(Canvas gx) {
gx.drawBitmap(fundo, 0, 0, null); paint.setTypeface(Typeface.DEFAULT_BOLD); paint.setTextSize(14); if (simulando) { paint.setColor(Color.BLUE); gx.drawText(rodando, 10, paint.getTextSize() + 3, paint); } else { paint.setColor(Color.RED); gx.drawText(parado, 10, paint.getTextSize() + 3, paint); } Vec2 centroImagem = normalizarCoordenadas(bola. getWorldCenter()); paint.setAntiAlias(true); paint.setFilterBitmap(true); paint.setDither(true); Retangulo rect = criarRetangulo(bola); float angulo = rect.angulo * 57.2957795f; gx.save(); gx.rotate(angulo, centroImagem.x, centroImagem.y); gx.drawBitmap(imagemBola, rect.x, rect.y, null); gx.restore(); }
Primeiramente, desenhamos a imagem de fundo com o método “drawBitmap”, do objeto “Canvas”. Depois, escrevemos uma mensagem indicando se o game loop está sendo executado ou não. Para desenhar a imagem, precisamos obter o ângulo em que o corpo da bola está, o que é feito no método “criarRetangulo()” (igual ao da versão anterior), e precisamos transformá-lo em graus (1 radiano = 57,2957795 graus). Para mudar o ângulo, fazemos de forma semelhante à versão Javax / Swing, ou seja, salvamos a matriz de desenho atual, rotacionamos a imagem, desenhamos e depois restauramos a matriz de desenho.
Capítulo 5 - Física de games — 131
Como aplicar “Anti-aliasing” na imagem Quando trabalhamos com games e temos que reescalar imagens, é comum nos depararmos com o problema de “aliasing”, no qual a imagem aparece “serrilhada”, como nos videogames antigos. Além de acrescentarmos “Dither” ao carregar e reescalar o bitmap, podemos mudar algumas propriedades do objeto “Paint” para obter um resultado melhor: • paint.setAntiAlias(true); • paint.setFilterBitmap(true); • paint.setDither(true); Porém, tome cuidado, pois ao aplicar o filtro Anti-alias, você vai acrescentar mais uma etapa ao seu loop de renderização (a aplicação do filtro no bitmap), o que pode fazer seu game rodar mais lento. Se você tiver uma quantidade muito grande de bitmaps, é melhor reescalar como eu fiz e não aplicar filtro na renderização. No caso do Android, o resultado não melhora muito, mas no caso do iOS faz toda a diferença. Nós rotacionamos em torno do centro da imagem, que foi obtido a partir do centro de massa da bola, em coordenadas globais normalizadas (temos que inverter o “y” e aplicar o fator de escala). O método “drawBitmap()” usa a imagem e as coordenadas do canto superior esquerdo para desenhar. Antes de concluir, tive que fazer uma coisa muito importante: lidar com as pausas. Em um dispositivo móvel, é comum o usuário mudar de aplicação constantemente. Por exemplo, ele pode estar jogando e receber um telefonema. Se o seu programa ficar executando a animação mesmo em pausa, estará roubando ciclos preciosos de CPU, além de memória, à toa. E o game poderá fazer coisas ruins, sem que o jogador possa interferir. Toda Activity tem dois métodos: “onPause()” e “onResume()”, que podem ser utilizados para lidarmos com isso. No “onPause()”, nós pausamos o jogo, e no “onResume()” nós continuamos de onde paramos, sem inicializar o mundo Box2D: @Override protected void onPause() { super.onPause(); if (simulando) { timer.cancel(); task = null; } } @Override protected void onResume() {
132 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS super.onResume(); if (simulando) { runGameLoop(); } }
O resultado de rodar a aplicação em um dispositivo Android pode ser visto na imagem seguinte. Eu rodei no meu LG Optimus P500 (um pouco antigo, mas confiável), com CyanogemMod 7 (Android 2.3.4), sem problemas.
Ilustração 50: A simulação sendo executada em um dispositivo Android real
Cuidado com o emulador Android! O emulador é uma “máquina virtual”, que é executada dentro do seu computador, logo, possui uma série de restrições. Uma delas é a capacidade de processamento. Em outras palavras, sempre que puder, desenvolva seu game rodando em um dispositivo real (dá até para depurar), e não no emulador. Este mesmo programa em um emulador roda cerca de 1/3 mais lento do que no aparelho e não adianta mudar FPS nem intervalo de Game loop.
Box2D no iOS Usar o Box2D no iOS é “mamão com açúcar”, já que podemos inserir código C e C++ diretamente nos arquivos-fonte em Objective C. Então, não precisamos do JBox2D e podemos baixar diretamente a última versão do Box2D C++ no site: www.box2d.org. O código-fonte do exemplo está em: “...\Codigo\iOS\Box2DTestbed.zip”.
Capítulo 5 - Física de games — 133
Preparando o projeto Para começar, crie um projeto de aplicação iOS no Xcode. Mais uma vez, neste livro eu não pretendo ensinar a fazer isto, mas, se quiser, pode ler o meu outro livro “Mobile Game Jam” (www.mobilegamejam.com) para ver como se faz. Após baixar e descompactar o Box2D_vX.X.X, copie a subpasta “Box2D” para dentro do seu projeto no Xcode. Temos que alterar algumas coisas no projeto do Xcode. Para começar, os arquivos do Box2D são feitos em C++ e nós vamos usar elementos definidos neles dentro do nosso código Objective C, então, temos que informar ao compilador que nosso código é misto. Podemos fazer isto de duas formas: mudar a extensão das implementações de “.m” para “.mm”, ou mudar a opção do próprio compilador, o que é mais fácil. Para mudar a opção do compilador: 1. Clique na raiz do projeto; 2. Clique em “Targets”; 3. Selecione a aba “Build Settings”, na janela do meio; 4. Procure a opção “Compile sources as...” e mude para “Objective-C++”;
Ilustração 51: Como alterar a configuração do código-fonte
Precisamos informar ao compilador onde procurar por arquivos de cabeçalho “.h”. Nós usamos a diretiva “#import” para incluir arquivos “header”. Quando queremos usar nossos próprios arquivos, os informamos entre aspas duplas. Porém, quando queremos usar alguma biblioteca, usamos entre os caracteres “<” e “>”. Veja o exemplo:
134 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS #import #import “B2DTBGraphicView.h”
Porém, o código-fonte do Box2D se refere aos seus próprios “includes” usando “<” e “>”, o que pode ser um problema para usá-lo dentro de nosso projeto. Como o Box2D não é uma “Library” normal e está dentro do nosso projeto, o compilador não vai encontrar os “headers”. Então, temos que adicionar nosso projeto aos locais onde o compilador deve procurar por “headers”. Para isto, na mesma janela “Build Settings”, procure a opção “Header Search Paths” e acrescente o seguinte texto: ${PROJECT_DIR}/**
Eu sei. Configurar o Xcode é um pouco chato, mas o projeto que está no código-fonte (“Box2DTestbed.zip”) já está devidamente configurado e você pode usar. Rodando no iOS
Ilustração 52: A aplicação rodando no Simulador iOS
A aplicação roda muito bem no iOS e a programação é basicamente a mesma da versão Android. Vamos nos concentrar nas diferenças. Para começar, temos que determinar o tipo de projeto. Eu escolhi “Universal”, logo, ele rodará no iPhone e iPad. Eu criei uma instância de “UIView” chamada “B2DTBGraphicView”, que deverá ser a classe da view nos dois arquivos de “Storyboard”: o do iPhone e o do iPad. Para isto, é só selecionar a aba “identity” e mudar a classe.
Capítulo 5 - Física de games — 135
Eu tenho uma classe View Controller chamada: “B2DTBViewController”, que recebe todos os eventos e também processa o Game loop. Eu criei dois loops separados: um “Game loop” e um “Render loop”, controlado pela classe de “View” (“B2DTBGraphicView”). O View controller Nosso View Controller começa (método “viewDidLoad”) mudando algumas propriedades na nossa View: _gview.clearsContextBeforeDrawing = YES; _gview.vc = self; UITapGestureRecognizer *umToque = [[UITapGestureRecognizer alloc] initWithTarget:self selector(umToqueNaTela)]; [umToque setNumberOfTapsRequired:1]; [umToque setNumberOfTouchesRequired:1]; [_gview addGestureRecognizer:umToque];
action:@
A propriedade “clearContextBeforeDrawing” faz com que o buffer de desenho seja limpo antes do método “drawRect”, da “View”, ser invocado. Depois, eu crio uma referência direta para o View Controller, e adiciono um reconhecedor de toque. Na versão Android, eu solicitava que o usuário pressionasse a tecla “MENU” para iniciar o Game loop. Como os dispositivos iOS não possuem essa tecla, eu simplesmente já começo com o Game loop rodando e uso o toque para aplicar a força à bola. O método “inicializarVariaveis” faz o que o nome diz e inicia o Game Loop: - (void) inicializarVariaveis { gameRodando = false; world = nil; FPS = 30.0f; raioBola = 5; bolaPercentScreen = 1.0f; timeStep = 1.0f / FPS; updateInterval = timeStep/3;
136 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS velocityIterations = 6; positionIterations = 2; // Tela CGRect screenBounds = [[UIScreen mainScreen] bounds]; CGFloat screenScale = [[UIScreen mainScreen] scale]; screenSize = CGSizeMake(screenBounds.size.width * screenScale, screenBounds.size.height * screenScale); // Escala e janelas: alturaImagem = screenSize.width; larguraImagem = screenSize.height; int raioReal = round((larguraImagem bolaPercentScreen) /100); fatorEscalaVisual = raioReal;
*
UIImage* image = [UIImage imageNamed:@”cenario2. png”]; fundo = image.CGImage; fundo = [self resizeImage:fundo newSize:CGSizeMak e(larguraImagem, alturaImagem)]; image = [UIImage imageNamed:@”bola.png”]; imagemBola = image.CGImage; imagemBola = [self resizeImage:imagemBola newSize:CGSizeMake(10.0f * fatorEscalaVisual, 10.0f * fatorEscalaVisual)]; alturaMundo = alturaImagem / fatorEscalaVisual; larguraMundo = larguraImagem / fatorEscalaVisual; _gview.tamanhoImagem = CGSizeMake(larguraImagem, alturaImagem); _gview.tamanhoMundo = CGSizeMake(larguraMundo, alturaMundo); _gview.fatorEscalaVisual = fatorEscalaVisual; _gview.fundo = fundo; _gview.bolaImagem = imagemBola;
}
gameRodando = NO; [self startGameLoop];
Capítulo 5 - Física de games — 137
Eu começo inicializando e calculando o “timeStep” para atualizar o mundo Box2D. Depois, eu uso o tamanho da tela para calcular o fator de escala visual. A bola sempre terá o raio proporcional à largura da tela. Isto faz com que ela seja menor em iPhones e maior em iPads. O meu “Game loop” é bem simples e é iniciado pelo método “startGameLoop”: -(void) startGameLoop { if (!gameRodando) { if (world) { [self deleteWorld]; } [self initBox2D]; [_gview startUpdating]; gameRodando = YES; aTimer = [NSTimer scheduledTimerWithTimeInterval: updateInterval target:self selector:@selector(gameLoop) userInfo:nil repeats:YES]; } }
Eu usei um tipo de mecanismo bem simples, pois o objetivo aqui é mostrar como usar o Box2D. Eu fiz como no Android: criei um disparador que roda de acordo com a taxa de FPS (frames por segundo) do jogo. Não está profissional ainda, mas serve para este propósito. O Game loop em sí é executado pelo método “gameLoop”: - (void) gameLoop { @synchronized(self) { world->Step(timeStep, velocityIterations, positionIterations); } }
É importante notar o uso da diretiva “@synchronized”. Eu sincronizei o acesso a este código, com base no objeto “View Controler”, de modo a evitar leituras “fantasmas” do “Render loop”, pois o método “Step” atualiza a posição dos corpos que eu criei no Box2D.
138 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
A criação dos objetos no Box2D No iOS, nós usamos a versão C++ do Box2D, logo, a sintaxe de criação de objetos muda um pouco. Vamos ver como eu criei o “chão”: // Criamos o chao b2BodyDef * chaoDef = new b2BodyDef(); chaoDef->position.Set(larguraMundo / 2, 2.5f); chaoDef->type = b2_staticBody; chao = world->CreateBody(chaoDef); float larguraChao = larguraMundo - 2.5f; b2Vec2 * chaoTamanho = new b2Vec2(larguraChao, 2.5f); b2PolygonShape * chaoShape = new b2PolygonShape(); chaoShape->SetAsBox(chaoTamanho->x / 2, 1.25f); b2FixtureDef * chaoFixDef = new b2FixtureDef(); chaoFixDef->shape = chaoShape; chaoFixDef->friction = 0.5f; chaoFixDef->density = 10.0f; chao->CreateFixture(chaoFixDef);
Para começar, todas as classes e estruturas usam o prefixo “b2”. Nós informamos o tipo de objeto como uma propriedade da instância de “b2BodyDef”, neste caso: “b2_staticBody”. Apesar disto, a semântica é a mesma e você pode conferir tudo no manual do Box2D: http://box2d.org/ manual.pdf. A classe “View” A nossa classe “View” implementa um “Render loop” em separado do “Game loop”, que é iniciado pelo método “startUpdating”: - (void) startUpdating { updateInterval = 0.03; aTimer = [NSTimer scheduledTimerWithTimeInterval: updateInterval target:self selector:@selector(timerFired) userInfo:nil repeats:YES]; }
O método “timerFired” é disparado a cada 1/FPS segundos:
Capítulo 5 - Física de games — 139 - (void) timerFired { [self setNeedsDisplay]; }
Este método simplesmente invalida a view, forçando o método “drawRect” a ser invocado, e é nesse método que eu desenho a situação atual do “mundo” Box2D: - (void)drawRect:(CGRect)rect { if (!world) { // Ainda não tem nada no “mundo”... return; } @synchronized(vc) { CGContextRef context = UIGraphicsGetCurrentContext(); CGContextDrawImage ( context, CGRectMake(0, 0, self.tamanhoImagem.width, self.tamanhoImagem.height), fundo); B2DTBRetangulo * rectBola = [self criarRetangulo:bola tamanho: CGSizeMake(10.0f, 10.0f)]; CGContextSaveGState(context); CGFloat sinA = sin(rectBola.angulo); CGFloat cosA = cos(rectBola.angulo); CGFloat x = rectBola.x + rectBola.tamanho.width / 2; CGFloat y = rectBola.y + rectBola.tamanho.height / 2; CGAffineTransform transform = CGAffineTransformMake(cosA,sinA,-sinA,cosA,xx*cosA+y*sinA, y-x*sinA-y*cosA); CGContextConcatCTM(context, transform); CGContextDrawImage (context, CGRectMake(rectBola.x, rectBola.y, rectBola.tamanho.width, rectBola.tamanho.height),
140 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
}
}
self.bolaImagem); CGContextRestoreGState(context);
Bem, o que eu estou fazendo no método “drawRect” é a mesma coisa que fiz no método “onDraw”, da minha “View” na versão Android: 1. Desenho o fundo; 2. Calculo o retângulo da bola, com a posição atual devidamente “normalizada”; 3. Rotaciono a bola de acordo com o ângulo atual; Porém, a rotação no Quartz 2D (o mecanismo gráfico do iOS que eu estou usando) é mais complexo. Alterando a matriz de transformação O Quartz 2D usa uma matriz de transformação, que é aplicada a um determinado ponto, de modo a obter sua nova coordenada. Funciona desta forma: a b 0 [ x ' y '1] = [ xy1] × c d 0 t x t x 1 Cada ponto a ser desenhado é calculado com base nesta matriz. As coordenadas transformadas são calculadas de acordo com as equações: x ' = ax + cy + t x y ' = bx + dy + t x A transformação que eu defini foi: CGAffineTransformMake(cosA,sinA,-sinA,cosA,x-x*cosA+y*sinA, y-x*sinA-y*cosA);
De acordo com a definição da função “CGAffineTransformMake”: CGAffineTransform CGAffineTransformMake ( CGFloat a, CGFloat b, CGFloat c, CGFloat d, CGFloat tx, CGFloat ty );
Capítulo 5 - Física de games — 141
Então: • a = cosseno do ângulo da rotação (cos(a)); • b = seno do ângulo da rotação (sen(a)); • c = -1 x sen(a); • d = cos(a); • tx = x – x * cos(a) + y * sen(a); • ty = y – x * sen(a) – y * cos(a). Aplicando nas duas equações: • x’ = cos(a) * x + (-1 * sen(a)) * y + (x – x * cos(a) + y * sen(a)); • y’ = sen(a) * x + cos(a) * y + (y – x * sen(a) – y * cos(a)); Calma! Antes de jogar o livro pela janela, eu quero dizer que esta é uma “receita de bolo”, que você pode usar para qualquer rotação de imagem! Eu só quero dar uma breve explicação de como isto é feito. Se quiser entender a matemática por trás desta operação, veja em: • Rotação: http://en.wikipedia.org/wiki/Rotation_(mathematics) • Translação: http://en.wikipedia.org/wiki/Translation_(geometry) No Android, nós podemos rotacionar o contexto especificando um ponto de “eixo”, que pode ser o centro da imagem. No iOS, não existe isso. Temos que combinar uma rotação com translação. Eu estou deslocando a origem (o eixo de rotação) para o ponto especificado nas variáveis “tx” e “ty”, calculado com base no ângulo que eu desejo rotacionar a imagem. Depois, eu especifico a rotação que eu desejo aplicar. O ângulo é em Radianos, obtido diretamente do Box2D. Como eu disse, é uma “receita de bolo”, ou seja, sempre que você precisar rotacionar uma imagem em torno do seu centro, use a mesma transformação, só variando, obviamente, as coordenadas da imagem e o ângulo. Note que o ponto inicial do retângulo é o canto superior esquerdo. Para concatenar a minha matriz de transformação com a matriz normal do contexto, eu uso o método: • CGContextConcatCTM(context, transform); Eu preciso salvar e restaurar a matriz do contexto, caso contrário, todos os desenhos serão afetados pela transformação que eu criei. Eu faço isso com os métodos: • CGContextSaveGState(context); • CGContextRestoreGState(context);
142 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Proteção contra concorrência Eu estou usando a diretiva “@synchronized” cercando todos os comandos de desenho, pois não quero pegar dados incompletos, o que pode acontecer se o Game loop estiver rodando ao mesmo tempo que o Render loop. Note que eu estou sincronizando com o mesmo objeto “View Controler”. Vamos ver o que aconteceria: 1. O “Thread” do Game loop obtém o “lock” do objeto “View Controller” e começa a atualizar as coordenadas dos corpos no mundo Box2D; 2. O “Thread” do Render loop tenta obter o “lock” do “View Controller”, de modo a desenhar os objetos nas posições atuais. Ele será bloqueado até que o Game loop libere o “lock”; O bloco protegido não precisa ser executado pelos dois “Threads”. O importante é que o objeto utilizado para sincronizar seja o mesmo (a mesma instância). Assim, cada “Thread” só entra no código “sensível” se conseguir obter o “lock” do objeto sincronizador. Finalmente Neste exemplo, minha preocupação é apenas mostrar como usar o Box2D em aplicações iOS. Eu estou utilizando o Quartz 2D para desenhar e mecanismos muito simples para animar a aplicação. Depois, veremos técnicas mais avançadas, que permitirão criarmos games melhores.
Capítulo 6 Renderização com OpenGL ES 2.0 Certamente, você já ouviu falar sobre OpenGL, e aposto que também já ouviu que muitos games foram desenvolvidos com ele, como: Doom 3
Half life
Minecraft
Quake
Second Life
StarCraft
Portal World of Warcraft
No mundo dos dispositivos móveis, nós temos a especificação OpenGL ES (OpenGL for Embedded Systems). Mas o que é OpenGL? Quais as vantagens de usá-la? OpenGL é uma especificação de API (Application Programming Interface) multiplataforma, criada pela empresa Silicon Graphics em 1992, e atualmente mantida pelo grupo Khronos, uma reunião de empresas interessadas em manter e desenvolver padrões para tratamento de mídia rica. Fazem parte do grupo Khronos, entre outras empresas: AMD, Apple, Intel, NVidia, Qualcomm, Samsung entre diversas outras. Várias empresas criam produtos que seguem as especificações do grupo Khronos, incluindo a especificação OpenGL. Outras criam produtos que renderizam gráficos utilizando produtos baseados em OpenGL. As vantagens de usar OpenGL para desenvolver aplicações podem ser resumidas em: 1. Uso de uma API padronizada e multiplataforma, o que simplifica o porte da aplicação; 2. Possibilidade de maior controle sobre a renderização dos seus gráficos; 3. Usar a aceleração gráfica de hardware para melhorar o desempenho de aplicações de mídia interativa; Aceleração gráfica de hardware é o serviço provido pela GPU (Graphics Processing Unit) do dispositivo. A GPU é um Chip especializado em processamento de imagens, que podem aumentar o desempenho de aplicações
144 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
interativas. O processamento de gráficos é dividido entre rotinas processadas pela CPU e rotinas processadas pela GPU, resultando em renderização mais suave e com menos “lags”, especialmente de gráficos em 3D. Algumas pessoas consideram o uso de aceleração gráfica de hardware como a única vantagem de utilizar OpenGL, porém, as três vantagens em conjunto é que tornam interessante o investimento em capacitação e utilização desta tecnologia. Existem alternativas ao uso da OpenGL, por exemplo o DirectX, da Microsoft, utilizado em PCs, Smartphones com Windows Phone e no console de games Xbox. No Android, nós temos o recurso do Canvas (pacote “android.graphics”) para desenhar na tela. Quando desenhamos usando Canvas, não estamos usando o OpenGL ES, o que pode trazer alguns problemas, caso seu game seja muito dinâmico. No iOS, usamos o Quartz com o Core Animation para desenhar em Views, o que também pode trazer alguns problemas no caso de games em tempo real. A alternativa para quem quer criar games dinâmicos, com animações em tempo real, é utilizar o OpenGL ES.
OpenGL ES A especificação OpenGL ES foi criada para sistemas embarcados, como dispositivos móveis e esta API pode ser utilizada tanto em sistemas baseados em Android como iOS. Atualmente, existem duas versões implementadas nos dispositivos: OpenGL ES 1.x e OpenGL ES 2.0, com suporte diferenciado: • Android com nível de API inferior a 8 (Froyo): OpenGL ES 1.x; • Android com nível de API igual ou superior a 8: OpenGL ES 1.x e 2.0 (em hardware que suporte OpenGL ES 2.0); • iOS versão inferior a 3: OpenGL ES 1.x; • iOS versão igual ou superior a 3: OpenGL ES 1.x e 2.0 (o hardware tem que ser: iPhone 3GS ou superior ou iPad 2.0 ou superior); Ambas as versões estão disponíveis na maioria dos dispositivos modernos (Android >= Froyo e iOS >= iPhone 3GS) e são utilizadas até hoje. A principal diferença entre elas é quanto ao Pipeline de renderização. As GPUs mais antigas possuíam sistemas dedicados para funções específicas, por exemplo: transformação e iluminação, que podiam ser configurados através das funções da API OpenGL. Para estas GPUs mais antigas, a opção é utilizar a versão 1.x. As GPUs mais modernas possuem processadores de uso geral,
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 145
que podem executar várias funções e podem ser programados. Para estas GPUs, a recomendação é utilizar a versão 2.0 (ou 3.0 em breve!) Neste livro, vamos nos concentrar na versão da OpenGL ES 2.0, que está presente na maioria dos dispositivos modernos e vamos ver apenas imagens em 2D, que estão dentro do escopo, porém, os conceitos se aplicam também às imagens 3D. A API OpenGL ES permite um controle total sobre como queremos que nossas imagens sejam renderizadas. Podemos até criar programas para sobreamento (Shaders), especificando como a luz incidirá sobre os vértices do nosso desenho, e estes programas serão executados na GPU, tornando o processamento do Game loop muito mais rápido. Mas isto tem um custo: complexidade! A API OpenGL é tudo, menos fácil de aprender. É complexa, com conceitos diferentes do que estamos acostumados, e a curva de aprendizado é longa. A maioria das funções pode ser feita de diversas maneiras e, dependendo da escolha, teremos melhor ou pior performance. Realmente, o uso de OpenGL representa um risco considerável em projetos de games e sua curva de aprendizado não deve ser menosprezada. Um programador iniciante em plataforma Android consegue criar um programa simples, que desenhe uma imagem em uma view (usando “onDraw()”) em questão de minutos. Um programador experiente em Android consegue criar um programa que desenhe a mesma imagem com OpenGL ES em questão de horas! Afinal, eu devo ou não usar OpenGL no meu game? Para mim, existem três vantagens em utilizar OpenGL: API padronizada, maior controle e uso de aceleração gráfica. Devemos considerar as três vantagens em conjunto. Por exemplo, ao escrever a renderização de um Game usando OpenGL ES, fica mais fácil portá-lo de Android para iOS, por exemplo, afinal a API está presente em ambas as plataformas. Se eu aprender a usar OpenGL ES, não preciso estudar detalhadamente os mecanismos de desenho de cada plataforma. Agora, as outras vantagens dependem mais da complexidade e dinamismo do game. Se você vai criar um game 3D, com certeza deveria investir em OpenGL ES, porém, mesmo em games 2D, o uso de OpenGL ES pode trazer os benefícios de padronização, controle e desempenho esperados. A maioria dos desenvolvedores de games móveis, tanto Android como iOS, recomendam o uso do OpenGL ES, ao invés dos mecanismos normais (Canvas e Quartz).
146 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ao usar OpenGL ES, transferimos algum processamento para a GPU, liberando recursos da CPU para outras tarefas, como o cálculo de física do game, por exemplo. Porém, não espere que isto resolva todos os problemas de “lag” do seu game. Existem atitudes que podem ser tomadas ANTES de considerar o uso ou não de OpenGL ES, como a criação de um Game Loop consistente, o gerenciamento eficiente de memória, pré-carga de imagens etc.
Fundamentos Quando desenhamos com mecanismos nativos, geralmente nós obtemos um contexto gráfico e utilizamos funções da API para desenhar na tela, por exemplo: • Android: no método “onDraw()” obtemos um “Canvas” e usamos o método “drawBitmap()”; • iOS: no método “drawRect:(CGRect)rect” obtemos o contexto gráfico atual (UIGraphicsGetCurrentContext) e desenhamos usando a função “CGContextDrawImage”; Daqui para a frente, eu me referenciarei ao “motor” (engine) gráfico OpenGL, logo, vou passar a escrever “o OpenGL”, para diferenciar da especificação “OpenGL ES”. O “motor” é uma implementação da especificação OpenGL ES 2.0. No OpenGL (OpenGL ES 2.0), o processo é diferente. Para começar, nós criamos programas de sombreamento (Shaders), além de vetores de vértices e textura, depois criamos as matrizes de transformação e projeção e mandamos a GPU renderizar a imagem. A primeira coisa que temos que aprender é como o OpenGL enxerga as coordenadas das imagens, que é um plano cartesiano tridimensional, onde o eixo das ordenadas cresce do centro para “cima”, ao contrário do plano utilizado nas telas dos dispositivos móveis (o “y” cresce de “cima” para “baixo”).
Coordenadas dos vértices e da textura Embora seja possível, nós não vamos desenhar linhas ou figuras geométricas. Em games, nós utilizamos “sprites”, que são imagens. Nós precisamos estabelecer as coordenadas dos cantos da imagem, ou vértices. Para começar, vamos imaginar aquele ídolo que usamos nos exemplos anteriores. É uma figura simples, um quadrado com desenhos imitando os “olhos” e um nariz.
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 147
Ilustração 53: a imagem que vamos usar
Esta imagem tem 4 vértices. Independentemente de seu tamanho, podemos imaginá-la centralizada em um plano tridimensional da seguinte forma:
Ilustração 54: A imagem centralizada no plano cartesiano
148 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Talvez, você esteja se perguntando: “Mas qual é a unidade?” Na verdade, isso não importa. Podemos pensar na unidade que desejarmos. Eu escolhi o intervalo [-2,2] porque fica fácil de imaginar. Textura No OpenGL ES 1.x, o uso de texturas possuía uma limitação: todas deveriam ser “power of two” (POT), ou seja, suas dimensões deveriam ser potências de 2. O OpenGL ES 2.x suporta texturas NPOT (non-power of two) com duas condições: 1. Você deve usar filtro linear; 2. Você deve usar WRAP MODE: CLAMP_TO_EDGE; Mesmo assim, já ví problemas estranhos acontecerem com texturas, por exemplo, no meu LG Optimus p500 (Android) eu vejo a textura normal, quando migro a aplicação para outra plataforma ou outro aparelho (como meu iPAD), vejo a textura com problemas. O problema acontece quando usamos filtro diferente de linear, ou usamos MipMaps (veremos mais adiante) e a textura precisa ser reduzida. Alguns chips gráficos conseguem lidar corretamente com texturas NPOT mesmo com Mipmaps, outros não. Eu recomendaria que você sempre utilizasse texturas POT, ou seja, com tamanhos em potências de 2 e não precisam ser quadradas, desde que ambas as dimensões sejam POT. Para facilitar, eis uma tabelinha simples: 22 23 24 25 26 27 28 29 210 211 212
4 8 16 32 64 128 256 512 1024 2048 4096
Cada dispositivo tem um tamanho máximo de textura. Em plataformas Android, podemos usar o seguinte código para verificar isso: int [] tamanho = new int[1]; GLES20.glGetIntegerv(GLES20.GL_MAX_TEXTURE_SIZE, tamanho,0);
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 149 Log.d(“RENDERER”, “Tamanho Maximo: “ + tamanho[0]);
No meu dispositivo Android, um Smartphone LG p500, ele retornou o valor 4.096. Mas o que isso quer dizer? Afinal, uma imagem de 64 x 64 pixels já atinge esse valor... Parece que esta informação é o maior tamanho que uma dimensão de textura pode ter (altura ou largura). Em dispositivos iOS, podemos usar os comandos: int tamanhoMaximo; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &tamanhoMaximo);
Nos dispositivos iOS de iPhone 4 para cima, o tamanho máximo é 2048 x 2048 pixels. Diante disto, eu redimensionei as imagens para potências de 2, por exemplo, os ídolos ficaram com: 128 x 128 pixels. Como vamos usar uma textura em nosso quadrado, precisamos especificar como ela será ajustada nele. As coordenadas de textura (s, t) variam de 0 a 1 e especificam como a imagem será aplicada a cada vértice.
Ilustração 55: As coordenadas de textura
Na verdade, as coordenadas de textura indicam “textels” ao invés de “pixels”. Um textel não tem tamanho físico definido. Um textel é a cor que determinado pixel da imagem terá, de acordo com sua posição no mapa de textura. Os valores das coordenadas “s” e “t” da textura precisam ser aplicados aos vértices da imagem (de acordo com sua forma). Então, saberemos qual é a parte da imagem que será mapeada naquele exato vértice. O OpenGL vai calcular
150 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
qual é a cor de cada pixel da imagem baseado na distância do ponto até os vértices. As coordenadas dos vértices de textura de um objeto OpenGL são chamadas de “u” e “v”, e correspondem às coordenadas “s” e “t”. A diferença que que “s” e “t” variam de 0 a 1, e “u” e “v” podem variar fora deste intervalo, neste caso as configurações de “wrap” de textura influenciarão o resultado. Se quisermos mapear a textura exatamente sobre os vértices, usamos coordenadas de textura entre 0 e 1.
Buffers O OpenGL sempre utiliza buffers para renderizar as imagens e nós também nos comunicamos com ele através deste tipo de estrutura. Como vimos anteriormente, temos que criar dois vetores float (no OpenGL, as coordenadas são sempre números reais): um com as coordenadas do “quadrado” que queremos texturizar e outro com as coordenadas de aplicação da textura. Porém, para passar essas informações para a GPU, o OpenGL utiliza buffers, que são áreas de memória contínua. O problema é que a especificação é muito aberta e podemos passar vários tipos de informação dentro de buffers: • Posições: coordenadas de cada vértice (x, y, z) do nosso polígono; • Cores: a intensidade (entre 0 e 1) de cada cor (RGBA) em cada vértice do polígono; • Normais: coordenadas de vetores “normais” (perpendiculares) a cada vértice do nosso polígono. São utilizados para calcular como a luz vai refletir em cada plano; • Texturas: a cobertura de textura (s, t) em cada vértice do nosso polígono; E tem mais! Podemos compactar mais de uma informação em um mesmo buffer (Packed Buffers). Para não complicar, nos exemplos que eu vou mostrar eu uso buffers separados e não especifico as cores nem os normais. Isto porque estou ensinando o OpenGL como ferramenta para criação de jogos 2D (inicialmente) e estas duas informações não são imprescindíveis. Buffer de posições Para começar, temos que criar um vetor de posições dos vértices do nosso polígono, depois, nós o transformaremos em um Buffer. O importante é que este vetor tem que ser criado em uma ordem específica: • Canto inferior esquerdo;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 151
• Canto superior esquerdo; • Canto inferior direito; • Canto superior direito; Vamos ver um exemplo (Android): static float squareCoords[] = { -2.0f, -2.0f, 0.0f, // canto inferior esquerdo -2.0f, 2.0f, 0.0f, // canto superior esquerdo 2.0f, -2.0f, 0.0f, // canto inferior direito 2.0f, 2.0f, 0.0f // canto superior direito };
Por que nesta ordem? Isto está relacionado à maneira como o OpenGL desenha as imagens. Se forem mais de dois vértices, ele vai desenhar triângulos (explicaremos mais adiante). Buffer de textura Agora, como vamos “pintar” o quadrado com uma textura, temos que especificar o vetor que indicará como a textura será aplicada aos vértices. Veja um exemplo em Android: static float textureCoords[] = { };
0.0f, 0.0f, 1.0f, 1.0f,
1.0f, 0.0f, 1.0f, 0.0f
// // // //
canto canto canto canto
superior inferior superior inferior
esquerdo esquerdo direito direito
A ordem de especificação das coordenadas de textura deve ser sempre esta e é diferente da ordem da especificação das coordenadas de posição da imagem. Podemos criar dois tipos de buffer: “Client buffers”, que ficam residentes em área de memória controlada pela CPU, e “Vertex Buffer Objects”, que ficam residentes em área de memória controlada pela GPU. Se usarmos “Client buffers”, nós teremos que copiá-los para a memória da GPU a cada vez que precisarmos renderizar imagens, o que resulta em mau desempenho. Se os dados dos vértices e da textura não mudarem, podemos gerar VBOs (Vertex Buffer Objects) diretamente na memória controlada pela GPU, evitando este tráfego de dados a todo momento. Nós criamos buffers remotos utilizando a função “glGenBuffers()” e obtemos ponteiros para os VBOs criados, depois, copiamos nossos dados (nossos
152 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
vetores) para dentro dos VBOs com a função “glBufferData()”. Para usar o buffer, só precisamos invocar a função “glBindBuffer()”.
Programas de sombreamento A especificação OpenGL ES 2.0 nos permite criar programas de sombreamento (ou “Shaders”) para serem executados pela GPU. Estes programas são criados usando a linguagem GLSL (http://www.khronos.org/registry/gles/ specs/2.0/GLSL_ES_Specification_1.0.17.pdf). Para renderizar e texturizar nossa imagem, precisamos criar pelo menos dois “Shaders”: Vertex Shader e Fragment Shader. Os Shaders são invocados no pipeline do OpenGL ES para determinar como cada vértice e pixel será renderizado. Você pode ver uma imagem do pipeline do OpenGL no site do grupo Khronos: http://www.khronos.org/opengles/2_X/. O OpenGL trabalha com comandos primitivos de desenho e dados de formatação de pontos. Os comandos primitivos de desenho, eis os principais: • “Points” (pontos): uma série de pontos individuais; • “Line Strips” (tiras de linhas): uma série de uma ou mais linhas concectadas, sendo os vértices os pontos de ligação; • “Line Loops” (polígonos): São semelhantes a “Line Strips”, exceto que um segmento adicional é criado para ligar o último ao primeiro vértice; • “Triangle strips” (tiras de triângulos): é uma série de triângulos conectados por um lado compartilhado; • “Triangle fans” (ventilador triangular): semelhante ao “Triangle Strip”, só que todos compartilham o vértice inicial; Qualquer figura que você queira desenhar terá que se encaixar em uma das primitivas. Eu estou usando sempre “Triangle Strips”, daí o ordenamento dos meus vértices no buffer. Bem, após processar a primitiva, o OpenGL ES vai invocar o “Vertex Shader”, que serve para mapear a posição de cada vértice do nosso polígono nas coordenadas da tela. Nós temos que fornecer um “Vertex Shader” para que o OpenGL utilize. Para isto, nós criamos um texto contendo os comandos em linguagem GLSL, o compilamos e depois o linkeditamos junto com o “Fragment Shader”. O “Fragment Shader” é processado mais no final do pipeline e trabalha sobre cada pixel a ser renderizado, de modo a determinar sua cor, iluminação e textura. Ele também deve ser criado como um texto em GLSL, compilado e linkeditado junto com o “Vertex Shader”.
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 153
Ambos os Shaders se comunicam através de parâmetros. O Vertex Shader pode mudar coordenadas, gerando a entrada para o resto do Pipeline. O Fragment Shader pode alterar valores de luz e cor para cada pixel. Parece complicado, não? E é! Mas permite um controle absoluto sobre o processo de renderização do OpenGL. Vou mostrar o Vertex Shader que eu criei para o exemplo que vou demonstrar: uniform mat4 uMVPMatrix; attribute vec4 aPosition; attribute vec2 aTextureCoord; varying vec2 vTextureCoord; void main() { gl_Position = uMVPMatrix * aPosition; vTextureCoord = aTextureCoord; }
Antes de mais nada, vamos esclarecer os modificadores das variáveis: • “uniform”: declara uma variável global “read only”, para uso durante o processamento da primitiva. Neste caso, estamos declarando uma matriz (veremos adiante) de projeção; • “attribute”: declara um atributo (parâmetro) passado ao Shader pelo OpenGL. Neste caso, estamos passando um vetor com a posição do vértice (vec4 é um vetor matemático com quadro campos do tipo “float”). Também estamos recebendo a coordenada de textura do vértice; • “varying”: um parâmetro que será passado ao Fragment Shader; A função “main()” é invocada quando o Shader for executado. Para começar, nós recebemos a posição de um vértice baseada no modelo, então, precisamos calcular qual será a nova posição, baseados na matriz de transformação (veremos adiante). Então, fazemos a multiplicação (de matrizes) das coordenadas do vértice e a matriz de transformação. Isto nos dará a nova posição do vértice na tela. A variável “gl_Position” é a principal saída do Vertex Shader e será a coordenada utilizada para renderizar o vértice (o Vertex Shader calcula as novas coordenadas de cada vértice). Como estamos utilizando uma textura, então temos que repassar ao Fragment Shader a coordenada de textura deste vértice. Simplesmente copiamos nosso atributo para um “varying”. Agora, vamos ver o código do Fragment Shader:
154 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS precision mediump float; varying vec2 vTextureCoord; uniform sampler2D sTexture; void main() { gl_FragColor = texture2D(sTexture, vTextureCoord); }
Para começar, declaramos que a precisão do modificador “mediump” será “float”. O “mediump” é o modificador de precisão utilizado para especificar dados de fragmentos. Depois, declaramos nosso atributo “varying”, que foi gerado no Vertex Shader (a coordenada de textura), e também a nossa textura, representada por uma variável global read-only do tipo “sampler2D”. Depois, simplesmente aplicamos nossa textura à coordenada de textura para sabermos qual será a cor do fragmento. A variável “gl_FragColor” é a principal saída do Fragment Shader. É importante notar que, para os vértices, o Fragment Shader receberá nossas coordenadas de textura, mas para os outros pixels será uma interpolação da coordenada de textura naquele determinado ponto.
Matrizes Apesar do OpenGL utilizar um plano cartesiano tridimensional, as telas dos dispositivos são 2D, logo, temos que realizar uma “transformação” nas coordenadas para criar uma projeção 2D de uma imagem 3D. Nós já falamos um pouco sobre isso no capítulo sobre fundamentos. Só para relembrar, vamos repetir a imagem.
Ilustração 56: Projeção de gráfico 3D em tela 2D
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 155
O OpenGL sempre trabalha com matrizes (da álgebra linear) para calcular posição, projeção e visão. Ele multiplica as coordenadas de cada vértice pelo produto das três matrizes, de modo a obter a coordenada final. Eu não pretendo entrar em muitos detalhes sobre matrizes, porém é importante notar que todas as matrizes são 4 x 4 e as coordenadas que usamos são chamadas de “coordenadas homogêneas”, nas quais um vértice é representado pelo conjunto: (x, y, z, w). O “w” é o divisor dos valores das outras coordenadas, permitindo ajustar a posição relativa dos objetos em profundidade. Nós não vamos usar diretamente as coordenadas homogêneas neste livro, porém, as matrizes devem prever isso (4 X 4). No OpenGL, sempre que necessitarmos mover, redimensionar ou girar um objeto, nós evitamos alterar o vetor de coordenadas. Simplesmente, criamos uma matriz de transformação do modelo ou “matriz modelo”, que contém as primeiras transformações a serem aplicadas ao nosso modelo (definido pelas coordenadas de vértice). Se quisermos manter nosso objeto no mesmo local, sem alterar tamanho e ângulo de rotação, podemos inicializar a matriz modelo com a matriz identidade, que não afeta as coordenadas dos vértices (é a mesma coisa que multiplicar por 1). Além da matriz modelo, existe a matriz de visão ou de câmera (view matrix), que indica como o observador verá a renderização. Como a matriz modelo é sempre multiplicada pela de câmera, normalmente tratamos o produto como “Model-View Matrix” (matriz de modelo e visão). Para projetar a imagem 3D em um plano 2D (a tela), usamos uma terceira matriz, que é chamada de matriz de projeção, e também é multiplicada pelo produto das outras duas. A coordenada final será calculada pela multiplicação da coordenada do vértice pela matriz produto, resultante da multiplicação das três (modelo, câmera e projeção). T x = A× x
()
Onde: • x : vetor coluna contendo as coordenadas do ponto; • T : função de transformação linear; • A : matriz de transformação linear; Desta forma, o OpenGL calcula as novas coordenadas dos pontos da imagem em seu pipeline, pois nós informamos a matriz combinada (projeção e câmera) em nosso Vertex Shader: uniform mat4 uMVPMatrix; attribute vec4 aPosition;
156 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS attribute vec2 aTextureCoord; varying vec2 vTextureCoord; void main() { gl_Position = uMVPMatrix * aPosition; vTextureCoord = aTextureCoord; }
Note o parâmetro “uMVPMatrix” (M = Model, V = View – câmera, P = Projection). Ele receberá a matriz-produto que geramos, aplicando-a às posições dos vértices. A matriz de projeção leva em consideração vários fatores, como: altura e largura da tela, ângulo de visão, distâncias (perto, longe), e a matriz de câmera considera: o ponto de visão, a direção “para cima” e o ponto para onde estamos olhando. Ao multiplica-las, temos a matriz de transformação que deve ser aplicada aos pontos da imagem. Projeção perspectiva Para exibir um objeto 3D em um plano 2D, temos que escolher um tipo de projeção a ser aplicada, conforme expliquei no capítulo sobre fundamentos. Os dois tipos mais utilizados são: projeção perspectiva e projeção ortográfica (ou ortogonal). Na projeção perspectiva, o tamanho visual dos objetos é afetado pela distância, ou seja, objetos (ou arestas) mais distantes aparecem menores que objetos mais próximos. Para entender melhor como funciona a projeção perspectiva, vamos mostrar o que é um “Frustum” (ou trapézio) de visão.
Ilustração 57: Medidas utilizadas em projeção 3D
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 157
O “Frustum” é um sólido que parece uma pirâmide com a ponta cortada (um tronco). Todos os objetos cujas coordenadas (após a transformação) estiverem dentro dele, serão renderizados pelo OpenGL. Vamos ver as principais medidas: • Coordenadas da câmera (ou do olho): são os valores de “x”, “y”, e “z”, da localização da câmera (ou do olho); • Coordenada de cima: São os valores de “x”, “y” e “z” que indicam o sentido para “cima”, sob o ponto de vista da câmera. Normalmente, usamos (0,1,0); • Direção do olhar (ou da câmera): são as coordenadas para onde o vetor do olhar aponta. É para onde estamos olhando, neste caso, diretamente em frente; • Limites da visão: são linhas imaginárias que delimitam a área visível. Neste exemplo, estão em linhas pontilhadas; • Campo de visão: é a amplitude da visão ou o ângulo formado por duas linhas de visão contíguas. Se os planos forem retangulares, existirá um campo de visão vertical e um horizontal; • Plano perto: é um valor de “z” que determina o plano de visão (a própria tela); • Plano longe: é um valor de “z” que determina o limite máximo (em distância) de visão; • Frustum: é o sólido criado entre os limites da visão e os dois planos. Tem a forma de um trapézio. Tudo o que estiver dentro dele, ou intercetando seus limites, será renderizado. Tudo o que estiver fora dele, não será renderizado; Na parte inferior da figura, temos um corte longitudinal do trapézio. Note que os triângulos estão totalmente fora do trapézio, e não serão renderizados. O círculo maior será renderizado parcialmente e o pentágono será ocultado pelo retângulo. Um problema muito comum em programação OpenGL é a imagem distorcida ou mesmo “desaparecida”. Neste caso, há grande chance do problema estar relacionado com alguma das duas matrizes. Na verdade, estamos utilizando projeção em perspectiva, na qual os objetos mais distantes aparecem menores na tela 2D. Entender como configurar a projeção (e o “frustum”) é bem complexo. Por exemplo, os planos “perto” e “longe”, representam dois valores do eixo “z”. O plano “perto” é, na verdade, a própria tela. Só que as coordenadas de projeção são traduzidas posteriormente para NDC – Normalized Device Coordinates, o que gera um pouco de confusão.
158 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
O OpenGL trabalha internamente com coordenadas normalizadas, ou NDC (Normalized Device Coordinates). Os valores de cada eixo (“x”, “y” e “z”) variam de -1 a 1, e o eixo “z” é invertido. Os valores negativos apontam no sentido do observador. Este tipo de orientação do eixo “z” é chamado de “left handed”. Após todas as transformações, suas coordenadas serão mapeadas para o padrão NDC. Como nós trabalhamos com os valores do eixo “z” crescendo na nossa direção (“right handed”), temos que levar esta diferença em consideração quando especificarmos os valores de “perto” e “longe”.
Ilustração 58: Diferenças entre coordenadas visuais e NDC
Se olharmos apenas para nosso “frustum”, poderíamos bem imaginar que o plano “perto” seria 1.0, e o plano “longe” poderia ser -10.0, certo? Se você fizer isto, vai acabar recebendo uma exception. A regra para os valores dos planos de corte são: 1. Esquerdo tem que ser diferente do Direito; 2. Cima tem que ser diferente de Baixo; 3. Perto tem que ser diferente de Longe; 4. Perto e Longe não podem ser menores que zero; Bem, se “perto” e “longe” não podem ser menores que zero, então, como especificamos estes valores, se nossa “câmera” está apontada na direção do “-z”? Basicamente, nós negamos os valores de “perto” e “longe”, antes de os informarmos ao criar a projeção.
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 159
Lembre-se que as coordenadas normalmente são projetadas a partir da origem. Assim, um retângulo com centro na origem será projetado no plano “perto”. Quando aumentamos a distância do plano “perto”, ampliamos a projeção, da mesma forma que acontece quando aproximamos ou afastamos um projetor de uma tela.
Exemplo utilizando Android e projeção perspectiva Vamos criar um programa para exibir a imagem de dois ídolos fixas na tela. O código-fonte do exemplo está em: “..\Codigo\OpenGLAndroid\openglbasico1.zip”.
Ilustração 59: O resultado do exemplo rodando no Android
Eu modifiquei a imagem do ídolo para parecer mais antiga, com uma rachadura à direita, e feita de tijolos. Achei que ficou mais “game” assim. Note que na figura existem dois ídolos: um mais no alto e acima (cor dourada) e outro mais embaixo e à direita (avermelhado), que parece ser menor que o primeiro, aparentando estar por detrás dele. Na verdade, ambos os ídolos são quadrados com mesmo tamanho, só que o segundo está com coordenada “z” menor. Logo, a projeção perspectiva faz com que ele apareça menor que o primeiro. Este exemplo não executa animação alguma, apenas exibindo a imagem do ídolo na tela. Veremos como animar imagens mais adiante.
160 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Antes de mais nada, abra o arquivo “AndroidManifest.xml” e acrescente a seguinte linha (em negrito):
Aqui vale uma recomendação: conecte um dispositivo Android, com versão igual ou superior a 2.3, e desenvolva testando diretamente no aparelho. Não use o emulador. O emulador Android só roda OpenGL, a partir da versão da plataforma 4.0.3 r2, e você tem que indicar na configuração da AVD que vai utilizar a GPU do sistema host (o seu desktop). Mesmo assim, ele vai rodar muito lento. Eu não recomendo.
Entendendo a GLSurfaceView No ambiente Android, podemos usar OpenGL ES 2.0 em Java (Dalvik) ou em código nativo, utilizando o NDK. Qual seria a vantagem de programar diretamente em C++? Desempenho? Pode ser. Mas você deve lembrar que, desde a versão Froyo (2.2), o Android utiliza JIT (Just in time) para compilar classes Dalvik em código nativo, o que acelera muito a execução das aplicações. Uma das vantagens seria a utilização direta de bibliotecas em C++, como o OpenGL, e outra seria o provável aumento de desempenho, que só é benéfico em aplicações muito específicas. Hoje em dia, a maioria dos jogos em Android é feita utilizando Java (Dalvik) mesmo, logo, vamos abordar apenas o uso de OpenGL nas aplicações Java. O Android disponibiliza algumas classes e interfaces bem úteis para o uso do OpenGL (pacote: “android.opengl”), entre elas: • GLSurfaceView: uma classe de view dedicada, com Thread próprio para renderização, que pode ser acoplada a uma instância de “GLSurfaceView.Renderer”, para desenho na tela; • GLSurfaceView.Renderer: interface que determina o comportamento de um renderizador de imagens OpenGL; • GLES20: uma classe de conveniência, que “encapsula” as funções C++ do OpenGL, oferecendo métodos estáticos para utilizarmos; • Matrix: uma classe de conveniência para lidarmos com as matrizes de transformação (projeção e câmera), com vários métodos estáticos;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 161
• GLUtils: uma classe de conveniência para lidar com imagens e texturas; Para criar uma aplicação Android que use OpenGL, temos que criar três classes nossas: • Uma classe de view, derivada de “GLSurfaceView”; • Uma classe de renderização, que implemente a interface “GLSurfaceView.Renderer”; • Uma classe activity, que instancia as outras duas; A classe activy é muito simples e tem poucos métodos: public class OpenGLBasico1Activity extends Activity { private OpenGLBasico1View mView;
));
@Override protected void onCreate(Bundle icicle) { super.onCreate(icicle); mView = new OpenGLBasico1View(getApplication( }
setContentView(mView);
@Override protected void onPause() { super.onPause(); mView.onPause(); } @Override protected void onResume() { super.onResume(); mView.onResume(); } }
Note que delegamos os eventos de “pause” e “resume” para a instância da nossa view. O método “setContentView()” indica para a Activity que o seu conteúdo será fornecido pela nossa view OpenGL. A nossa classe de view “OpenGLBasico1View”, é igualmente simples, necessitando apenas do método construtor:
162 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS public class OpenGLBasico1View extends GLSurfaceView { public OpenGLBasico1View(Context context) { super(context); setEGLContextClientVersion(2); setRenderer(new OpenGLBasico1Renderer(context)); } }
É importante especificarmos qual é a versão do contexto OpenGL que vamos utilizar, neste caso “2” significa OpenGL ES 2.0. Também instanciamos a nossa classe de renderização e passamos para a view. Podemos também escolher como será a renderização: contínua ou sob demanda, utilizando o método “setRenderMode()”. Este método recebe um parâmetro indicando o tipo de renderização que desejamos. No caso da renderização contínua (“GLSurfaceView.RENDERMODE_CONTINUOUSLY”, default), a nossa classe de renderer será invocada para renderizar a imagem de forma contínua (loop). Se escolhermos renderização sob demanda (“GLSurfaceView.RENDERMODE_WHEN_DIRTY”), a imagem só será renderizada se invocarmos o método “requestRender()”. Bom, até agora é “molezinha”... A “mágica” acontece mesmo é na classe de renderização, que implementa a interface “GLSurfaceView.Renderer”, que possui três métodos: • “abstract void onDrawFrame(GL10 gl)” : invocado quando é necessário redesenhar um novo frame, utilizando os VBOs (Vector Object Buffers), texturas e matrizes; • “abstract void onSurfaceChanged(GL10 gl, int width, int height)” : invocado quando o tamanho da imagem mudou, normalmente quando o dispositivo é rotacionado. Aqui, devemos recalcular a matriz de projeção; • “abstract void onSurfaceCreated(GL10 gl, EGLConfig config)” : invocado quando a superfície é criada. Neste momento, nós inicializamos buffers, carregamos texturas e realizamos operações de inicialização que exigem um contexto OpenGL, como a compilação de Shaders; Bem, e agora? O que faremos? O trabalho é praticamente todo feito na nossa classe de renderização. Eu sigo esta lista: 1. Criar os vetores de vértices e textura, tomando cuidado com a orientação dos elementos;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 163
2. Criar o código-fonte dos Shaders (Vertex e Fragment), colocando-os dentro de Strings (ou então carregando de arquivos); 3. Criar o construtor e nele transformar os vetores em ByteBuffers, além de carregar a imagem que servirá de textura. Estas operações são basicamente feitas usando as APIs do Android e do Java, logo, não há necessidade de um contexto OpenGL; 4. Compilar ambos os Shaders e criar um “programa” contendo ambos, que será executado pela GPU. Isto é feito no método “onSurfaceCreated()”; 5. Fazer o “binding” (apontar) entre campos da nossa classe e os atributos dos Shaders no método “onSurfaceCreated()”. Assim, teremos como enviar informações para o processamento dos vértices e dos pixels; 6. Transformar a imagem que carregamos em uma textura OpenGL, no método “onSurfaceCreated()”; 7. Criar a matriz de câmera que vamos usar, no método “onSurfaceCreated()”; 8. Criar a matriz de projeção no método “onSurfaceChanged()”; 9. Desenhar a imagem no método “onDrawFrame()”; Não teremos um loop de animação, apenas a renderização pura e simples da imagem.
A implementação Vértices e textura A primeira coisa é criar os vetores de vértices e textura: static float squareCoords[] = { -2.0f, -2.0f, 0.0f, // canto inferior esquerdo -2.0f, 2.0f, 0.0f, // canto superior esquerdo 2.0f, -2.0f, 0.0f, // canto inferior direito 2.0f, 2.0f, 0.0f // canto superior direito }; static float squareCoords2[] = { 0.0f, -4.0f, -1.0f, // canto inferior esquerdo 0.0f, 0.0f, -1.0f, // canto superior esquerdo 4.0f, -4.0f, -1.0f, // canto inferior direito 4.0f, 0.0f, -1.0f // canto superior direito };
164 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS static float textureCoords[] = { 0.0f, 1.0f, // canto 0.0f, 0.0f, // canto 1.0f, 1.0f, // canto 1.0f, 0.0f // canto };
superior inferior superior inferior
esquerdo esquerdo direito direito
Temos 3 coordenadas para cada vértice (x, y, z) de nossos dois ídolos. Um ídolo será formado pelas suas coordenadas de vértice, suas coordenadas de textura e sua imagem. As coordenadas assumem que o centro do quadrado está no ponto de origem (0, 0, 0). As coordenadas de textura informam como a imagem “cobrirá” o quadrado. A ordem dos vértices e das coordenadas de textura são pré-determinadas e deve ser desta forma, para que o modo de renderização que voi usar (Triangle Strips) funcione corretamente. Mas, se vamos criar dois ídolos, com texturas e posições diferentes, não deveríamos ter dois vetores de textura? Não. O vetor de texturas diz como a imagem “cobrirá” os vértices, se for igual para as duas figuras, então não precisamos de dois vetores de textura. Escrever os Shaders Eu não coloquei o código-fonte dos Shaders em arquivos, mas poderia ter feito isto. Eu simplesmente criei dois Strings: private final String vertexShaderSource = “uniform mat4 uMVPMatrix;\n” + “attribute vec4 aPosition;\n” + “attribute vec2 aTextureCoord;\n” + “varying vec2 vTextureCoord;\n” + “void main() {\n” + “ gl_Position = uMVPMatrix * aPosition;\n” + “ vTextureCoord = aTextureCoord;\n” + “}\n”; private final String fragmentShaderSource = “precision mediump float;\n” + “varying vec2 vTextureCoord;\n” + “uniform sampler2D sTexture;\n” + “void main() {\n” + “ gl_FragColor = texture2D(sTexture, vTextureCoord);\n” + “}\n”;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 165
Variáveis auxiliares Note que eu declarei algumas variáveis que são ponteiros ou matrizes, como: • private int mTextureID : identificador (handler) da textura no OpenGL; • private FloatBuffer verticesQuadrado : buffer local para receber o vetor de vértices; • private FloatBuffer texturaQuadrado : buffer local para receber o vetor de coordenadas de textura; • private int[] buffers = new int[2] : identificadores dos dois VBOs que criaremos na GPU; • private float[] matrizProjecao = new float[16] : nossa matriz de projeção; • private float[] matrizCamera = new float[16] : nossa matriz de câmera; • private float[] matrizModelo = new float[16] : matriz “normal” do modelo, ou seja, onde aplicamos movimento e rotação. Neste exemplo, ela é sempre a matriz identidade, pois não desejamos mudar a posição do objeto; • private float[] matrizIntermediaria = new float[16] : usada para calcular a matriz final, que é a multiplicação das outras; • private int programaGLES : identificador do programa Shader que criamos. Ele contém o Vertex e o Fragment Shader compilados e linkeditados; • private int muMVPMatrixHandle : identificador da matriz de transformação que criamos e vamos passar para o OpenGL; • private int maPositionHandle : identificador do VBO de vértices; • private int maTextureHandle : identificador do VBO de textura; Inicialização dos buffers locais e carga da imagem Sugiro que você leia sobre as classes do pacote “java.nio”, especialmente as classes “ByteOrder”, “ByteBuffer” e “FloatBuffer”, ou então veja os exemplos de OpenGL do Android (http://developer.android.com/training/graphics/ opengl/shapes.html). É feita no construtor da classe. Primeiro, inicializamos um “ByteBuffer” com o tamanho do vetor de vértices (número de vértices multiplicado pelo tamanho de um “float”). Também informamos qual é o “byte order” nativo que estamos utilizando. Vamos criar dois buffers locais, um para cada ídolo:
166 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS // Vamos criar um buffer para passar para o OpenGL (que é em C): ByteBuffer bb = ByteBuffer.allocateDirect( // (# of coordinate values * 4 bytes per float) squareCoords.length * 4); bb.order(ByteOrder.nativeOrder()); verticesQuadrado = bb.asFloatBuffer(); verticesQuadrado.put(squareCoords); verticesQuadrado.position(0); ByteBuffer bb2 = ByteBuffer.allocateDirect( // (# of coordinate values * 4 bytes per float) squareCoords2.length * 4); bb2.order(ByteOrder.nativeOrder()); verticesQuadrado2 = bb2.asFloatBuffer(); verticesQuadrado2.put(squareCoords2); verticesQuadrado2.position(0);
Fazemos a mesma coisa com o buffer local de coordenadas de textura: ByteBuffer b2 = ByteBuffer.allocateDirect( textureCoords.length * 4); b2.order(ByteOrder.nativeOrder()); texturaQuadrado = b2.asFloatBuffer(); texturaQuadrado.put(textureCoords); texturaQuadrado.position(0);
Agora, vamos carregar a imagem que servirá de textura. Nós usamos as opções “Dither”, para criar um “ruído aleatório” (já falamos sobre isto), não queremos que ela seja redimensionada na carga e queremos usar o padrão RGBA, com quatro bytes para cada valor (a opção ARG_4444 gera uma imagem ruim): Options opc = new Options(); opc.inDither = true; opc.inScaled = false; opc.inPreferredConfig = Bitmap.Config.ARGB_8888; imagemIdolo = BitmapFactory.decodeResource(this.context. getResources(), R.drawable.idolo, opc); imagemIdolo2 = BitmapFactory.decodeResource(this.context. getResources(), R.drawable.idolo2, opc);
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 167
Compilar ambos os Shaders e criar um “programa” Esta parte parece complicada, mas é praticamente uma “receita de bolo”. O local para fazermos isto é no método “onSurfaceCreated()”. Primeiramente, nós compilamos o Vertex Shader: int iVertexShader = GLES20.glCreateShader(GLES20.GL_ VERTEX_SHADER); if (iVertexShader != 0) { GLES20.glShaderSource(iVertexShader, vertexShaderSource); GLES20.glCompileShader(iVertexShader); int[] compiled = new int[1]; GLES20.glGetShaderiv(iVertexShader, GLES20.GL_COMPILE_ STATUS, compiled, 0); if (compiled[0] == 0) { // Deu erro... Log.e(TAG, “Erro ao compilar o Vertex Shader: “); Log.e(TAG, GLES20.glGetShaderInfoLog(iVertex Shader)); GLES20.glDeleteShader(iVertexShader); iVertexShader = 0; return; } } else { Log.e(TAG, “Erro ao criar o Vertex Shader.”); return; }
O processo é bem mecânico: obter um identificador (handler) de Shader no OpenGL (glShaderCreate()), adicionar o código-fonte (glShaderSource()), compilar o código-fonte (glCompileShader()) e verificar o resultado da compilação (glGetShaderiv()). Repetimos o processo para o Fragment Shader. Com ambos os Shaders compilados, precisamos linkeditá-los e criar um programa na GPU. Nós usaremos o identificador (handler) deste programa para informar ao OpenGL no momento da renderização. Eis a criação do programa na GPU: programaGLES = GLES20.glCreateProgram(); if (programaGLES != 0) {
168 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS GLES20.glAttachShader(programaGLES, iVertexShader); checkGlError(“glAttachShader”); GLES20.glAttachShader(programaGLES, iFragmentShader); checkGlError(“glAttachShader”); GLES20.glLinkProgram(programaGLES); int[] linkStatus = new int[1]; GLES20.glGetProgramiv(programaGLES, GLES20.GL_LINK_STATUS, linkStatus, 0); if (linkStatus[0] != GLES20.GL_TRUE) { Log.e(TAG, “Não foi possível linkeditar o programa: “); Log.e(TAG, GLES20.glGetProgramInfoLog(programa GLES)); GLES20.glDeleteProgram(programaGLES); programaGLES = 0; return; } } else { Log.e(TAG, “Erro ao criar o Programa.”); return; }
É um procedimento igualmente mecânico: 1. Criamos o identificador (handler) do programa com “glCreateProgram()”; 2. Adicionamos o Vertex Shader compilado, utilizando o seu identificador no método “glAttachShader()”; 3. Adicionamos o Fragment Shader compilado, utilizando o seu identificador no método “glAttachShader()”; 4. Linkeditamos o programa com o método “glLinkProgram()”; 5. Verificamos o resultado com o método “glGetProgramiv()”; Fazer o “binding” dos atributos dos Shaders Para podermos enviar informações aos Shaders, precisamos atribuir identificadores aos seus atributos e “uniforms”, que também fazemos no método “onSurfaceCreated()”: maPositionHandle = GLES20.glGetAttribLocation(programaGLES , “aPosition”); checkGlError(“glGetAttribLocation aPosition”); if (maPositionHandle == -1) { throw new RuntimeException(“não localizei o
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 169 atributo aPosition”); } maTextureHandle = GLES20.glGetAttribLocation(programaGLES, “aTextureCoord”); checkGlError(“glGetAttribLocation aTextureCoord”); if (maTextureHandle == -1) { throw new RuntimeException(“não localizei o atributo aTextureCoord”); } muMVPMatrixHandle = GLES20.glGetUniformLocation(programaGLES, “uMVPMatrix”); checkGlError(“glGetUniformLocation uMVPMatrix”); if (muMVPMatrixHandle == -1) { throw new RuntimeException(“não localizei o atributo uMVPMatrix”); }
Se você voltar e observar o código dos Shaders, verá que nós temos 2 atributos e 1 “uniform”. Temos o atributo que indica a posição dos vértices, o atributo que indica a coordenada de textura e o “uniform” que indica a matriz de transformação. Depois destes comandos, nós temos identificadores para cada um destes atributos e podemos enviar valores para eles. Transformar a imagem que carregamos em uma textura OpenGL Nós temos um “Bitmap” com a imagem do ídolo, mas precisamos criar uma textura na GPU. Isto é feito no método “onSurfaceCreated()”: // Finalmente, vamos criar as texturas int[] texturas = new int[2]; GLES20.glGenTextures(2, texturas, 0); mTextureID = texturas[0]; GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureID); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_ TEXTURE_MIN_FILTER, GLES20.GL_NEAREST); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
170 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, TEXTURE_WRAP_S, GLES20.GL_REPEAT); GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, TEXTURE_WRAP_T, GLES20.GL_REPEAT);
GLES20.GL_ GLES20.GL_
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, imagemIdolo, 0); imagemIdolo.recycle(); mTextureID2 = texturas[1]; GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureID2); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_ TEXTURE_MIN_FILTER, GLES20.GL_NEAREST); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_ TEXTURE_WRAP_S, GLES20.GL_REPEAT); GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_ TEXTURE_WRAP_T, GLES20.GL_REPEAT); GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, imagemIdolo2, 0); imagemIdolo2.recycle();
Primeiro, precisamos criar os identificadores de textura. Depois de criar, nós indicamos para o OpenGL, que são texturas 2D, e passamos alguns parâmetros para elas. O método “glTexParameterf()” passa um parâmetro do tipo “float” e o método “glTexParameteri()” passa um parâmetro do tipo “int”: • GL_TEXTURE_MIN_FILTER = GL_NEAREST : usado para informar o nível mínimo de detalhes quando a textura é diminuída. “GL_NEAREST” indica que deve ser utilizado o elemento de textura mais próximo das coordenadas; • GL_TEXTURE_MAG_FILTER = GL_LINEAR : usado para informar o nível de detalhe quando a textura é aumentada. “GL_LINEAR” retora a média ponderada dos elementos que estão mais próximos das coordenadas;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 171
• GL_TEXTURE_WRAP_S = GL_REPEAT : o que deve ser feito se o tamanho da área for maior que o da textura. “GL_REPEAT” permite que eu simplesmente repita a imagem, criando um efeito de “ladrilho”. O “S” é para a coordenada inicial da textura; • GL_TEXTURE_WRAP_T = GL_REPEAT : A mesma coisa, só que para a coordenada final da textura; Finalmente, transferimos a imagem para a GPU com “texImage2D()” e reciclamos o bitmap, já que não vamos necessitar mais dele. Se você ficar criando texturas a cada momento eu seu game, é importante apagá-las da memória da GPU, quando não estiverem mais em uso. Para isto, use o método: “glDeleteTextures()”: int [] texBuffers = {idTextura}; GLES20.glDeleteTextures(1, texBuffers, 0);
Criar a matriz de câmera Antes de mais nada, vamos ver como projetamos o nosso “Frustum” de visão para este exemplo:
Ilustração 60: Frustum de visão
Precisamos criar uma matriz com o ponto de visão do observador. Nós vamos utilizar a classe “Matrix”, do Android, para isto. Ainda no método “onSurfaceCreated()”, vamos inicializar nossa variável “matrizCamera”: // Vamos posicionar os olhos do usuário atrás do ponto de origem float x = 0.0f; float y = 0.0f; float z = 7.0f; // Estamos olhando para a frente float ox = 0.0f;
172 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS float oy = 0.0f; float oz = -5.0f; // Vetor que aponta a direção “para cima” float cx = 0.0f; float cy = 1.0f; float cz = 0.0f; Matrix.setLookAtM(matrizCamera, 0, x, y, z, ox, oy, oz, cx, cy, cz);
As coordenadas dos câmeras são informadas primeiro (0, 0, -7), depois, informamos o ponto de direção do olhar (0, 0, 0), e, finalmente, o vetor que aponta para o sentido de “cima” (0, 1, 0). Criar a matriz de projeção O método “onSurfaceChanged()” será invocado na primeira vez que a superfície for criada e, cada vez que for modificada, ou seja, tiver seu tamanho modificado. No nosso caso, quando o dispositivo for rotacionado. Neste método, temos que considerar os novos valores de altura e largura de tela para criar uma matriz de projeção. Os planos Longe e Perto devem representar retângulos de proporções semelhantes. Neste caso, criamos uma nova matriz de projeção, que será utilizada na geração da matriz de transformação final: public void onSurfaceChanged(GL10 gl, int width, int height) { // A View foi redimensionada (pode acontecer no início e se mudar a orientação) GLES20.glViewport(0, 0, width, height); float proporcao = (float) width / height; final float esquerda = -proporcao; final float direita = proporcao; final float baixo = -1.0f; final float cima = 1.0f; final float perto = 1.0f; final float longe = 10.0f; Matrix.frustumM(matrizProjecao, baixo, cima, perto, longe); }
0,
esquerda,
direita,
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 173
O método “glViewPort()” indica qual é o tamanho da nossa tela e indica como as coordenadas da imagem devem ser transformadas para ficarem de acordo com a tela. Depois, criamos uma matriz de projeção informando seis planos de corte. Dois planos verticais, esquerdo e direito, baseados na proporção entre altura e largura da tela, dois planos horizontais, baixo e cima, e dois planos verticais baseados na distância, perto e longe. Os planos esquerdo e direito recebem, respectivamente: -proporção e +proporção, o que serve para ajustar o sistema de coordenadas quadrado do OpenGL para uma tela retangular. Desenhar a imagem No método “onDrawFrame()” nós vamos, finalmente, desenhar alguma coisa na tela. A primeira coisa que temos que fazer é limpar a tela e preparar para desenhar usando nossos Shaders: GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT COLOR_BUFFER_BIT); GLES20.glUseProgram(programaGLES);
|
GLES20.GL_
O método “glClear()” limpa os buffers informados. Estamos limpando o buffer de profundidade e o de cor. A cor utilizada para limpar pode ser definida com o método “glClearColor()”. Como não o utilizamos, a cor é (0,0,0,0) (preto transparente). E eu tenho que indicar o identificador do programa da GPU que vou utilizar para processar as operações de sombreamento (Vertex e Fragment). Eu passo o identificador do programa que eu criei e compilei. Como vamos usar objetos em planos “z” diferentes, queremos que os objetos que estão “na frente” cubram os que “estão atrás”, logo, habilitamos o teste de profundidade automático: GLES20.glEnable(GLES20.GL_DEPTH_TEST);
Se não fizermos isto, teremos que tomar cuidado com a ordem em que desenhamos os objetos, que deve ser inversamente proporcional à distância do observador. Depois, é hora de preparar a textura que vamos usar: GLES20.glActiveTexture(GLES20.GL_TEXTURE0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureID);
Cada dispositivo de vídeo (GPU) pode ter um determinado número de texturas ativas simultaneamente. O OpenGL determina isso através de “Texture Units”. Neste caso, estou ativando a unidade de textura zero (GL_TEXTURE0) e estou associando o identificador (handler) da minha textura a ela. Isto
174 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
significa que a minha textura será utilizada na próxima operação de renderização. São duas texturas diferentes, então, eu tenho que fazer isto para cada ídolo. Agora, tenho que indicar quais são os buffers que vou utilizar para renderizar os vértices e aplicar a textura. Lembre-se que eles já estão na GPU, logo, não passamos ponteiros para buffers locais. Também temos que habilitar os vetores, o que fará com que as coordenadas sejam passadas aos atributos dos Shaders: // Vértices: GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, buffers[0]); GLES20.glVertexAttribPointer(maPositionHandle, 3, GLES20.GL_FLOAT, false,12, 0); GLES20.glEnableVertexAttribArray(maPositionHandle); // Textura: GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, buffers[1]); checkGlError(“glEnableVertexAttribArraymaPositionHandle”); GLES20.glVertexAttribPointer(maTextureHandle, 2, GLES20.GL_FLOAT, false, 8, 0); GLES20.glEnableVertexAttribArray(maTextureHandle);
O método “glBindBuffer()” ativa um VBO da GPU. Como eu tenho os indicadores (handlers) dentro do meu vetor “buffers”, é só selecionar qual eu desejo ativar. Depois, com o método “glVertexAttribPointer()”, eu indico para a GPU qual é o buffer de coordenadas que eu quero usar, informando o indicador “maPositionHandle”. Eu tenho que dar informações sobre seu conteúdo, neste caso: o número de coordenadas por vértice (3), o formato de dados de cada coordenada (“float”), se estão normalizadas (entre -1 e 1) e o tamanho total das coordenadas de cada vértice (12 bytes, 4 por coordenada). O último argumento é o deslocamento desta informação dentro do buffer (no nosso caso, temos buffers separados, então é zero mesmo). Isto também servirá para indicar de onde o OpenGL vai retirar o argumento de posição a ser passado para os Shaders. Finalmente, com o método “glEnableVertexAttribArray()”, eu habilito o vetor que acabei de passar para a GPU. Depois, eu repito tudo para desenhar o segundo ídolo.
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 175
Agora, é o momento de criar nossa matriz de transformação e informá-la aos Shaders: Matrix.setIdentityM(matrizModelo, 0); Matrix.multiplyMM(matrizIntermediaria, 0, matrizCamera, 0, matrizModelo, 0); Matrix.multiplyMM(matrizIntermediaria, 0, matrizProjecao, 0, matrizIntermediaria, 0); GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, matrizIntermediaria, 0);
Como eu não estou modificando a posição da imagem, estou criando minha matriz modelo com a matriz identidade (não altera nada nas coordenadas dos pontos). Então, eu a multiplico pela matriz de câmera e depois multiplico o resultado pela matriz de projeção, obtendo a matriz de transformação final. Eu repeti o cálculo das matrizes para os dois ídolos, embora isto não seja necessário neste caso, pois cada ídolo usa matriz modelo identidade e as mesmas matrizes de câmera e projeção. Então, eu uso o método “glUniformMatrix4fv()” para atribuir a matriz de transformação ao “uniform” “uMVPMatrix”, que eu defini no Vertex Shader. Assim, ele vai recalcular os vértices de acordo com o meu modelo, câmera e projeção, gerando as novas coordenadas. E, para concluir, eu invoco o método para desenhar a textura nos vértices transformados: GLES20.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, squareCoords.length / 3); checkGlError(“glDrawArrays”); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
Eu estou usando o modo “Triangle Strip” para desenhar 4 coordenadas de vértices. Note que é somente neste momento que eu informo quantos vértices serão desenhados. E, como usei VBOs, tenho que desativar o “bind” associando a zero.
Concluindo Terminamos o exemplo! Se rodarmos, teremos a frustrante sensação de ver imagens quadradas aparecendo na tela.Sem graça.Poderíamos ter feito isso
176 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
com muito menos código, não é mesmo? Enfim, OpenGL é assim mesmo. É por isso que eu disse que a curva de aprendizado é muito grande, e acredite: somente arranhamos a superfície do que é possível fazer com OpenGL. Entre as coisas que ficaram “de fora” estão: • Movimento (rotação, translação); • Efeitos de luz; • Imagens tridimensionais.
Exemplo utilizando iOS e projeção perspectiva Vamos criar um programa para exibir os dois ídolos fixos na tela, como fizemos com o sistema Android. O código-fonte do exemplo está em: “..\Codigo\OpenGLiOS\OpenGLIOSBasico1.zip”.
Ilustração 61: O resultado do exemplo rodando no iOS
Este exemplo não executa animação alguma, apenas exibindo a imagem dos ídolos na tela. Veremos como animar imagens mais adiante.
Entendendo o GLKit O iOS possui um framework chamado “GLKit” para desenvolver aplicações baseadas em OpenGL ES. O GLKit inclui: funções, classes e outros tipos de dados que facilitam o desenvolvimento de aplicações móveis utilizando o OpenGL ES. É claro que você não precisa usar o GLKit, mas é altamente recomendável que o faça.
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 177
A documentação completa sobre o GLKit pode ser lida em:
http://developer.apple.com/library/ios/#documentation/GLkit/Reference/GLKit_ Collection/Introduction/Introduction.html
As características mais importantes do GLKit que vamos usar são as classes: GLKViewController, GLKView, GLKBaseEffect e GLKTextureInfo.
GLKViewController O GLKViewController possui várias propriedades e métodos interessantes e já implementa um loop de renderização, inicialmente baseado em 30 FPS, o que pode ser mudado através da propriedade: “preferredFramesPerSecond”, limitado a 60 FPS. Ele possui alguns métodos (delegados de GLKViewControler ou de GLKView) que devemos sobrescrever, como: • “- (void)viewDidLoad” : devemos inicializar o contexto OpenGL, indicar a nossa GLKView e carregar todas as informações invariantes, como: VBOs, texturas etc; • “- (void)dealloc” : liberamos nossas estruturas alocadas na GPU (VBOs, programa etc), e o próprio contexto OpenGL; • “- (void)update” : devemos atualizar nosso modelo pois um novo frame será renderizado; • “- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect” : devemos renderizar o frame; Podemos usar o GLKViewController de duas maneiras: instanciando diretamente a classe e interceptando os métodos em um “delegate”, ou criar uma subclasse. Eu prefiro este último método, que é utilizado pelo “template” fornecido no Xcode (OpenGL ES Game). Aliás, este template não cria uma subclasse de GLKView, usando o View Controller como “delegate” dela. GLKBaseEffect Esta classe já fornece os Shaders necessários e permite usarmos até duas texturas em uma operação de desenho. Ela poupa muito trabalho, porém, se quisermos, podemos fazer da mesma forma que o Android e gerarmos nosso próprio programa e associarmos as texturas manualmente. GLKTextureInfo / GLKTextureLoader A classe GLKTextureLoader pode carregar texturas a partir de imagens em arquivos, gerando instâncias de GLKTextureInfo. Isto poupa todo o trabalho de carga e definição de texturas.
178 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Criando uma aplicação OpenGL ES A maneira mais simples de usar OpenGL no iOS é criar uma aplicação utilizando o template fornecido no Xcode (4.x ou superior). Para isto, abra o Xcode e crie uma nova aplicação selecionando “OpenGL Game” na janela de seleção de templates.
Ilustração 62: Como usar o template OpenGL no iOS
Após criar sua aplicação, você terá as seguintes classes: • XXXViewController; • XXXAppDelegate; • main; Se você mandar executar a aplicação no Simulador, verá que aparecem dois cubos rodando. Muito bacana, porém é um problema: se você quer apenas estudar, pode ser que a aplicação gerada pelo template seja útil, mas, se você quer usar a aplicação gerada para criar outra aplicação, tem muito “lixo” a ser removido.A Apple deveria criar um template OpenGL mais básico, talvez renderizando apenas um cubo ou triângulo, com uma textura simples. Bem, de qualquer forma, você poderá usar a aplicação exemplo (que vou mostrar) como seu próprio template, já que ela tem o mínimo necessário: vetor de vértices, vetor de textura, criação de VBOs, matriz de câmera e de projeção. Mesmo assim, o template tem implementações bem úteis: • Inicializa tudo no “viewDidLoad”; • Tem um método “setupGL”, que inicializa as estruturas na GPU; • Tem um método “tearDownGL”, que desaloca tudo;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 179
• Já carrega os Shaders a partir de arquivos, com o método “loadShaders”; • Compila e linkedita os Shaders, fazendo o “binding” dos atributos, nos métodos “loadShaders” e “compileShaders”; • Atualiza a matriz de modelo e calcula a projeção no método “update”; • Desenha a imagem no método “drawInRect”; Começamos adicionando a imagem do nosso ídolo ao grupo “Supporting Files”, e depois vamos customizar a subclasse de GLKViewController que o template gerou para nós. Eu recomendo apagar todo o conteúdo da implementação do View Controler (“XXXViewController.m”) e seguir o que vou definir adiante.
A implementação Nós vamos usar o mesmo “Frustum” do exemplo Android, com as mesmas configurações para matriz de câmera e de projeção. Abra a implementação do View Controller e apague tudo, só deixando até a linha em negrito: // // OGLB1ViewController.m // OpenGLIOSBasico1 // // Created by Cleuton Sampaio on 16/01/13. // Copyright (c) 2013 Cleuton Sampaio. All rights reserved. // #import “OGLB1ViewController.h” #define BUFFER_OFFSET(i) ((char *)NULL + (i))
Criar nossos vetores de vértices As coordenadas de vértices e de texturas são iguais às do Android:
// As coordenadas do nosso quadrado, que vai receber a imagem do ídolo. GLfloat squareCoords[12] = { -2.0f, -2.0f, 0.0f, // canto inferior esquerdo -2.0f, 2.0f, 0.0f, // canto superior esquerdo 2.0f, -2.0f, 0.0f, // canto inferior direito 2.0f, 2.0f, 0.0f // canto superior direito };
180 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS GLfloat squareCoords2[12] = { 0.0f, -4.0f, -1.0f, // canto inferior esquerdo 0.0f, 0.0f, -1.0f, // canto superior esquerdo 4.0f, -4.0f, -1.0f, // canto inferior direito 4.0f, 0.0f, -1.0f // canto superior direito }; GLfloat textureCoords[8] = { // Mapping coordinates for the vertices 0.0f, 1.0f, // canto superior esquerdo 0.0f, 0.0f, // canto inferior esquerdo 1.0f, 1.0f, // canto superior direito 1.0f, 0.0f // canto inferior direito }; @interface OGLB1ViewController () { GLuint _program; GLKMatrix4 matrizProjecao; GLKMatrix4 matrizModelo; GLKMatrix4 matrizCamera; float _rotation; GLuint _vertexBuffer; GLuint _textureBuffer; }
São exatamente os mesmos vetores que usamos na implementação Android, só que definidos em linguagem C. Na verdade, podemos salvar os vetores em arquivos e carregá-los, o que facilita mais ainda o desenvolvimento em múltiplas plataformas. Se você quiser realmente desenvolver games, pode criar um framework seu, que carregue de arquivos as várias configurações do OpenGL, como: • Coordenadas de vértices de cada Game Object; • Coordenadas de texturas, associadas a cada Game Object; • Dados do “Frustum”; • Código-fonte dos Shaders;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 181
Isto diminuiria o código-fonte da aplicação e reduziria o risco de divergências entre as plataformas. O que você terá que criar é o código que lê e aplica as configurações, de acordo com a versão (Android ou iOS). Depois, criamos uma extensão de classe para colocar nossas propriedades e métodos “privados”, dentro da implementação (“*.m”): @interface OGLB1ViewController () { GLuint _vertexBuffer; GLuint _vertexBuffer2; GLuint _textureBuffer; GLKMatrix4 matrizCamera; } @property @property @property @property
(strong, (strong, (strong) (strong)
nonatomic) EAGLContext *context; nonatomic) GLKBaseEffect *effect; GLKTextureInfo * textureInfo; GLKTextureInfo * textureInfo2;
- (void)setupGL; - (void)tearDownGL; @end
Uma extensão de classe nos permite criarmos propriedades e métodos internos, para uso apenas na implementação do View Controller. Começamos definindo variáveis para armazenar nossos indicadores (handlers) dos buffers remotos, além da nossa matriz de câmera. Depois, definimos como propriedades: o contexto OpenGL ES, o GLKBaseEffect, e duas variáveis GLKTextureInfo para armazenarem nossas texturas. Lembre-se de sintetizar os getters / setters com “@synthesize”. Note que criamos dois métodos internos: “setupGL”, que inicializa tudo, e “tearDownGL”, que termina tudo. Inicializando o View Controller Agora, temos que implementar o método “- (void)viewDidLoad” e inicializar nosso VC: - (void)viewDidLoad { [super viewDidLoad];
182 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS // Alocamos o contexto OpenGL ES self.context = [[EAGLContext alloc] initWithAPI:kE AGLRenderingAPIOpenGLES2]; if (!self.context) { NSLog(@”Failed to create ES context”); } GLKView *view = (GLKView *)self.view; view.context = self.context; [EAGLContext setCurrentContext:view.context]; view.drawableDepthFormat = GLKViewDrawableDepthFormat24; self.effect = [[GLKBaseEffect alloc] init]; self.effect.useConstantColor = GL_TRUE; glClearColor(0.0f, 0.0f, 0.0f, 1.0f); }
[self setupGL];
Começamos alocando nosso contexto OpenGL ES, informando que vamos utilizar a versão 2.0. Depois, fazemos um “cast” da view do nosso VC (todo View Controller tem uma propriedade “view”), para GLKView. Não precisamos nos preocupar se o “cast” é válido, pois o template “Open GL Game” já associa uma instância de GLKView à propriedade do VC. Associamos o contexto OpenGL ES à view e o tornamos corrente. Também especificamos a profundidade de cor que vamos usar para desenhar (24 bits). Então, inicializamos nosso GLKBaseEffect e o configuramos para usar as cores de vértices constantes, caso contrário, teríamos que especificar o vetor de cores, e especificarmos a cor de limpeza dos buffers (R, G, B, Alpha), no intervalo entre 0 e 1. Depois, invocamos o método “setupGL” para inicializarmos nossos vértices e texturas. Inicializando vértices e texturas Precisamos criar nossos buffers de vértices e carregar nossas texturas, da mesma forma que fizemos no Android: - (void)setupGL { glEnable(GL_DEPTH_TEST);
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 183 // Vetor de coordenadas idolo 1: glGenBuffers(1, &_vertexBuffer); glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer); glBufferData(GL_ARRAY_BUFFER, sizeof(squareCoords), squareCoords, GL_STATIC_DRAW); glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_ FLOAT, GL_FALSE, sizeof(GLfloat) * 3, BUFFER_OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribPosition); // Vetor de coordenadas idolo 2: glGenBuffers(1, &_vertexBuffer2); glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer2); glBufferData(GL_ARRAY_BUFFER, sizeof(squareCoords2), squareCoords2, GL_STATIC_DRAW); glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_ FLOAT, GL_FALSE, sizeof(GLfloat) * 3, BUFFER_OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribPosition); // Vetor de textura glGenBuffers(1, &_textureBuffer); glBindBuffer(GL_ARRAY_BUFFER, _textureBuffer); glBufferData(GL_ARRAY_BUFFER, sizeof(textureCoords), textureCoords, GL_STATIC_DRAW); glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_ FLOAT, GL_FALSE, sizeof(GLfloat) * 2, BUFFER_OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribTexCoord0);
184 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS // Carregando nossa textura: // NO iOS temos que desligar a opção para inverter, pois estamos usando // o OpenGL para renderizar, com o eixo Y correto, e não o do CGContext NSDictionary * options = [NSDictionary dictionaryWithObjectsAndKeys: [NSNumber numberWithBool:NO], GLKTextureLoaderOriginBottomLeft, nil]; NSError * error; NSString *path = [[NSBundle mainBundle] pathForResource:@”idolo.png” ofType:nil]; self.textureInfo = [GLKTextureLoader textureWithContentsOfFile:path options:options error:&error]; if (self.textureInfo == nil) { NSLog(@”Error loading file: %@”, [error localizedDescription]); return; } NSError *error2; NSString *path2 = [[NSBundle mainBundle] pathForResource:@”idolo2.png” ofType:nil]; self.textureInfo2 = [GLKTextureLoader textureWithContentsOfFile:path2 options:options error:&error2]; if (self.textureInfo2 == nil) { NSLog(@”Error loading file: %@”, localizedDescription]); return; }
[error
// Vamos posicionar a matriz de visão (Câmera)
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 185 // Vamos posicionar os olhos do usuário atrás do ponto de origem float x = 0.0f; float y = 0.0f; float z = 7.0f; // Estamos olhando para a frente float ox = 0.0f; float oy = 0.0f; float oz = -5.0f; // Vetor que aponta a direção “para cima” float cx = 0.0f; float cy = 1.0f; float cz = 0.0f; matrizCamera = GLKMatrix4MakeLookAt(x, y, z, ox, oy, oz, cx, cy, cz); }
O código é muito semelhante ao da versão Android. Habilitamos o teste de profundidade, definimos os VBOs das coordenadas dos vértices e da textura, e depois carregamos os arquivos de textura, usando a classe GLKTextureLoader, armazenando as informações das texturas nas nossas duas propriedades (textureInfo e textureInfo2). Note que tivemos que desligar a opção “GLKTextureLoaderOriginBottomLeft”, para evitar que as texturas fossem carregadas de cabeça para baixo (o “y” é carregado invertido). Finalmente, posicionamos nossa matriz de câmera, exatamente como fizemos no Android. Cuidado com memory leaks Se você carregar várias texturas dinamicamente, é melhor desalocar o buffer, quando não precisar mais dela. Você pode desalocar as texturas carregadas com a função “glDeleteTextures()”. Vamos imaginar que você não necessite mais da textura1, então, antes de carregar outra textura, inclua este código: GLuint hTextura = self.textureInfo.name; glDeleteTextures(1, &hTextura);
Outra medida importante é desfazer o “Bind Buffer”, após renderizar alguma coisa:
186 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS glBindBuffer(GL_ARRAY_BUFFER, 0);
Mas isto não apaga o VBO. Se você não necessitar mais dele, então é melhor mandar a GPU liberar a memória com a função “glDeleteBuffers()”: glDeleteBuffers(1, &_vertexBuffer);
Na verdade, devemos implementar o método “dealloc”: - (void)dealloc { [self tearDownGL];
}
if ([EAGLContext currentContext] == self.context) { [EAGLContext setCurrentContext:nil]; }
E no nosso método “tearDownGL”, nós nos desfazemos de tudo que alocamos: - (void)tearDownGL { [EAGLContext setCurrentContext:self.context]; GLuint hTextura = self.textureInfo.name; glDeleteTextures(1, &hTextura); hTextura = self.textureInfo2.name; glDeleteTextures(1, &hTextura); glDeleteBuffers(1, &_vertexBuffer); glDeleteBuffers(1, &_vertexBuffer2); glDeleteBuffers(1, &_textureBuffer); self.effect = nil; }
Atualizando o modelo Uma das coisas legais do GLKViewController é que ele já tem um loop de atualização e desenho embutido. O default é 30 FPS, mas você pode mudar isso com a propriedade “preferredFramesPerSecond”. Quando for a hora de atualizar o modelo, seu método “update” será invocado: - (void)update {
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 187 glViewport(0, 0, self.view.bounds.size.width, self. view.bounds.size.height); float aspect = fabsf(self.view.bounds.size.width / self.view.bounds.size.height); float float float float float float
esquerda = -aspect; direita = aspect; baixo = -1.0f; cima = 1.0f; perto = 1.0f; longe = 10.0f;
self.effect.transform.projectionMatrix GLKMatrix4MakeFrustum(esquerda, direita, baixo, cima, perto, longe);
=
}
Nós não vamos animar os objetos, mas precisamos calcular novamente a matriz de projeção, pois a tela pode ter sido redimensionada (rotação), então é o que fazemos. É muito semelhante ao método “onSurfaceChanged()” da implementação Android. Desenhando os ídolos O View Controller atua como “delegate” da GLKView, logo, podemos implementar o método “drawInRect”, que será invocado sempre que a view necessitar ser redesenhada: - (void)glkView:(GLKView *)view drawInRect:(CGRect) rect { // Limpar e preparar: glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Idolo 1: self.effect.texture2d0.name = self.textureInfo. name; self.effect.texture2d0.enabled = YES; // Matriz de posicionamento combinada: GLKMatrix4 matrizModelo = GLKMatrix4Identity;
//
188 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS Não estamos fazendo nada com ela... GLKMatrix4 matrizIntermediaria = GLKMatrix4Multip ly(matrizCamera, matrizModelo); self.effect.transform.modelviewMatrix = matrizIntermediaria; [self.effect prepareToDraw]; glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer); glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 3, BUFFER_ OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribPositi
on);
glBindBuffer(GL_ARRAY_BUFFER, _textureBuffer); glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 2, BUFFER_ OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribTexCoord0); glDrawArrays(GL_TRIANGLE_STRIP, 0, sizeof(squareCoords) / 3); // Idolo 2: self.effect.texture2d0.name = self.textureInfo2. name; self.effect.texture2d0.enabled = YES; [self.effect prepareToDraw]; glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer2); glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 3, BUFFER_ OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribPosition); glBindBuffer(GL_ARRAY_BUFFER, _textureBuffer); glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_ FLOAT, GL_FALSE, sizeof(GLfloat) * 2, BUFFER_OFFSET(0));
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 189 glEnableVertexAttribArray(GLKVertexAttribTexCoord0); glDrawArrays(GL_TRIANGLE_STRIP, 0, sizeof(squareCoords2) / 3); }
glBindBuffer(GL_ARRAY_BUFFER, 0);
Nós estamos usando o GLKBaseEffect para gerar os Shaders e controlar tudo. Então, para renderizar uma imagem, nós temos que indicar qual será a textura a ser utilizada. A propriedade “name”, da classe “GLKTextureInfo”, tem o handler da textura alocada. Para usar uma textura, precisamos informar isso ao GLKBaseEffect, que permite usar duas texturas simultanemente. Também precisamos habilitar o desenho de texturas 2D: self.effect.texture2d0.name = self.textureInfo. name; self.effect.texture2d0.enabled = YES;
Depois, temos que aplicar transformações ao nosso objeto, como: reposicionar, rotacionar etc. Nós fazemos isso na matriz de modelo. Como não estamos fazendo nada, simplesmente a inicializamos com a matriz identidade, multiplicando-a pela matriz de câmera para formar a “Model-View Matrix”. Note que informamos isso ao GLKBaseEffect: GLKMatrix4 matrizModelo = GLKMatrix4Identity; GLKMatrix4 matrizIntermediaria = GLKMatrix4Multiply(matrizCamera, matrizModelo); self.effect.transform.modelviewMatrix = matrizIntermediaria;
Antes de habilitar os buffers e desenhar, precisamos invocar o método “prepareToDraw”: [self.effect prepareToDraw];
Isto é necessário antes de qualquer desenho e temos que repetir sempre que alterarmos alguma propriedade do Base Effect. O resto é igual à versão Android: atribuímos os VBOs e mandamos desenhar. Depois, repetimos tudo para o segundo ídolo.
190 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Achatando as coisas A projeção perspectiva é legal, mas é muito mais complexa para trabalhar. Se você estiver criando Games 2D (ou mesmo “2.5D”, com ilusão tridimensional), não precisa disso tudo. A projeção ortográfica facilita muito o desenvolvimento. A forma da projeção é um paralelepípedo, limitado pelos planos: perto, longe, cima, baixo, direita e esquerda.
Ilustração 63: A projeção ortográfica
Com a projeção ortográfica, o tamanho dos objetos não é afetado por sua distância do observador.
Implementação em Android O exemplo está em: “..\Codigo\OpenGLAndroid\openglbasico2.zip”. Basicamente, mudamos a criação da matriz de projeção, que fica no método “onSurfaceChanged()”: @Override public void onSurfaceChanged(GL10 gl, int width, int height) { GLES20.glViewport(0, 0, width, height); float perto = 1.0f; float longe = 10.0f; float baixo = -1.0f; float cima = 1.0f; float proporcao = (float) width / (float) height; float esquerda = -proporcao; float direita = proporcao;
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 191 Matrix.orthoM(matrizProjecao, 0, esquerda * 5, direita * 5, baixo * 5, cima * 5, perto, longe); }
O método “orthoM” cria uma matriz ortográfica e associa à nossa variável “matrizProjecao”. Os parâmetros são: • A variável de referência para a matriz; • O deslocamento dentro da matriz, neste caso é zero; • Esquerda e direita representam a posição no eixo das abscissas (“x”) dos planos de corte esquerdo e direito; • Baixo e cima representam a posição no eixo das ordenadas (“y”) dos planos de corte inferior e superior; • Perto e longe representam a posição, no eixo “z” dos respectivos planos de corte; Para entender o motivo de eu ter especificado aqueles parâmetros, temos que entender uma diferença fundamental entre o “mundo” OpenGL e uma tela real. No OpenGL, a “tela” é quadrada, porém, nossas telas geralmente são retangulares. Logo, é preciso compensar a distorção utilizando a razão entre largura e altura da tela. Como fazemos isso? Bem, temos que especificar 6 planos: esquerda, direita, baixo, cima, perto e longe. Podemos começar calculando a razão entre largura e altura (não importa se a tela está de pé ou deitada): float proporcao = (float) width / (float) height;
Como dividimos a largura pela altura, podemos compensar especificando o plano esquerdo como: -1 * proporção, e o plano direito com o próprio valor da proporção. Depois, especificamos o plano superior como 1 e o inferior como -1. Assim, o OpenGL saberá renderizar corretamente nossa imagem.
Ilustração 64: Nosso ajuste esquerdo / direito / cima / baixo
192 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Assim, nossos ídolos aparecerão na proporção correta, como quadrados. Você deve ter duas perguntas, certo? 1. Por que eu usei a proporção apenas na esquerda e na direita? 2. Por que eu multipliquei por 5? A resposta da primeira pergunta é simples: eu quero projetar um plano quadrado em um plano retangular, logo, só posso usar a proporção em um par de planos. Neste caso, escolhi a esquerda e a direita. Se usasse a proporção nos quatro planos, estaria projetando em um plano quadrado, gerando distorção. Se você quiser usar a proporção nos planos de cima e de baixo, basta dividir a altura pela largura e não usar a proporção nos planos esquerda e direita. Para responder à segunda pergunta, eu devo lhe solicitar que analise o tamanho do nosso quadrado, pois cada lado tem 4 unidades (o OpenGL não especifica uma unidade de medida). Eu estou testando em um celular com tela de 320 x 480 pixels, logo, a imagem ficaria muito grande, pois a proporção é aproximadamente 0,6666... Ao multiplicar por 5, eu fico com a imagem mais afastada, pois a janela de projeção é maior. Neste exemplo, estamos associando cada unidade do OpenGL ao valor de 5 pixels de tela. Isto não é bom... Se usarmos um dispositivo com resolução maior, a imagem ficará muito pequena. Então, como nos prepararmos para isto? Uma resposta seria calcular o valor de cada unidade em pixels utilizando uma proporção. Podemos especificar que cada unidade OpenGL equivale a um percentual da tela, depois, calculamos quanto vale este percentual em pixels e o utilizamos para escalar a posição dos planos. Feito isto, é só rodar o aplicativo e teremos a imagem de dois ídolos, com o mesmo tamanho.
Ilustração 65: A projeção ortográfica em Android
Capítulo 6 - Renderização ������������������������������������ com OpenGL ES 2.0������ — 193
Implementação em iOS O exemplo está em: “..\Codigo\OpenGLiOS\OpenGLIOSBasico2.zip”. Nós mudamos a criação da matriz de projeção, que é feita no método “update”: - (void)update { glViewport(0, 0, self.view.bounds.size.width, self. view.bounds.size.height); float proporcao = fabsf(self.view.bounds.size.width / self.view.bounds.size.height); float float float float float float
perto = 1.0f; longe = 10.0f; baixo = -1.0f; cima = 1.0f; esquerda = -proporcao; direita = proporcao;
self.effect.transform.projectionMatrix = GLKMatrix4MakeOrtho(esquerda * 5, direita * 5, baixo * 5, cima * 5, perto, longe); }
E o resultado ficou muito parecido com o da versão Android.
Ilustração 66: Projeção ortográfica no iOS
Capítulo 7 Framework de Física e Renderização Agora, que já vimos o básico de OpenGL ES nas duas plataformas (Android e iOS), chegou o momento de juntarmos o Box2D e criarmos um exemplo animado. Para simplificar, pretendo usar o mesmo exemplo que já mostrei: a bola batendo nas paredes. Na próxima figura, vemos o resultado da execução do novo programa.
Ilustração 67: Box2D e OpenGL ES 2.0
E vamos começar com a versão Android, depois fazendo a versão iOS. As principais diferenças deste exemplo para o anterior são: 1. Teremos duas texturas; 2. Teremos que calcular a posição de acordo com o movimento; 3. Temos que atualizar a matriz de modelo e combiná-la com a de projeção e de câmera; Porém, eu quero fazer diferente... Por favor, abra o projeto anterior, tanto a versão Android, como a versão iOS e olhe bem o código. Aproveite, abra o do Box2D também. O que você vê? Para que serve a maioria do código-fonte? Eu vou dar um chute: configurar
196 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
o Box2D e o OpenGL ES! A parte do código dedicada ao processamento da lógica de “negócio” é muito pequena. Isto é o que eu chamo de “boilerplate code” (http://en.wikipedia.org/wiki/Boilerplate_code). Eu gosto de traduzir como “código de enchimento de linguiça”. Boilerplate code é código-fonte cuja função é apenas configurar estruturas e bibliotecas e que pode ser repetido em outras partes da aplicação, ou em outras aplicações, com alterações mínimas. Se você criar vários games OpenGL/Box2D, provavelmente vai repetir os mesmos comandos, com pouca variação. Além de tedioso e trabalhoso, o boilerplate code também aumenta o risco do projeto, pois você pode “esquecer” algum pequeno detalhe, que só aparecerá quando o game estiver no mercado. A necessidade desse boilerplate code imenso é a Complexidade acidental das soluções OpenGL ES e Box2D, que são extremamente verbosas. E note que é tudo código de configuração, ou seja, pode ser parametrizado. Sempre que transformamos configuração programática em declarativa, eliminamos risco de erros, além do mais, facilitamos a migração entre as duas plataformas-alvo deste livro: Android e iOS. O código-fonte está em: • Versão Android (2.3 ou superior): “..\Codigo\OpenGLAndroid\ openglavancado.zip”; • Versão iOS: “..\Codigo\OpenGLiOS\OpenGLIOSAvancado.zip”;
Um framework básico O objetivo deste livro é fornecer um “kit” de ferramentas e técnicas para desenvolvedores independentes de games (“indie game developers”), de modo a criarem games móveis para plataforma Android e iOS. Então, minha ênfase é facilitar o porte de games entre as duas plataformas, criando código-fonte parametrizável externamente. Então, neste capítulo, eu vou mostrar como integrar o Box2D com o OpenGL ES usando um framework básico que permita ler todas as configurações de um arquivo XML. Com isto, ainda teremos Boilerplate code, mas não vamos ter que copiá-lo, pois vamos usar herança para transmitir o comportamento para outras classes. Eu tenho o meu próprio framework, que foi evoluindo ao longo do tempo, mas acho que o melhor seria começar simples, mostrando como fazer as coisas básicas e deixar que você evolua seu framework como achar melhor. Este framework é básico, podendo e devendo ser estendido por você, de acordo com suas necessidades. Eu mesmo vou fazer isso em exemplos posteriores. Desta forma, eu não explicarei cada método do exemplo, nem vou
Capítulo 7 - Framework de Física e Renderização — 197
mostrar em separado. Se você ler este capítulo e estudar os exemplos, saberá como integrar Box2D com OpenGL ES e também como usar este framework em seus próprios games. Limitações Este framework não é completo e nem serve para todos os tipos de game. Para começar, eu assumo que todas as texturas serão retangulares e só estou prevendo objetos Box2D com colisores circulares ou retangulares. Se você precisar, pode alterar o framework para acomodar outros tipos, ou, então, escrever código suplementar, por fora do framework. Vamos ver algumas das configurações que incluí no framework.
O esquema XML do arquivo de modelo de game O game é autoconfigurável através de um arquivo XML. Neste arquivo, eu descrevo cada cena do game (entenda como nível), suas características físicas e visuais, além dos Game Objects que pertencem a ela. Assim, posso usar o mesmo código-fonte para criar diversos games. Eu criei um esquema XML para descrever o arquivo, mas não é possível validar o XML em todas as plataformas. Eu recomendaria que você criasse uma aplicação desktop, para gerar o XML já validado, algo como um “game editor”.
Ilustração 68: O esquema XML
198 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
O arquivo do esquema XML está dentro dos dois projetos deste capítulo (não use o modelo dos capítulos seguintes!): • Arquivo ZIP: “..\Codigo\OpenGLAndroid\openglavancado.zip”; • Arquivo de esquema: “modeloGame.xsd”. Eu criei um modelo de classes baseado no esquema XML, tanto no Android (Java) como no iOS (Objective C). Vamos analisar o diagrama de classes do modelo Android, só para entender o relacionamento dos elementos.
Ilustração 69: Diagrama de classes
Um GameModel é composto por Cenas, que indicam como as várias “cenas” (ou níveis) do game devem ser configuradas. Uma Cena contém: • Número: identificador da cena; • Textura de fundo: imagem “background”, que será exibida como fundo de tela (use sempre imagens com tamanho em “potência de 2”; • FPS: taxa de frames por segundo para configurar a atualização do mundo Box2D; • ConfigFrustum: o nome “frustum” não é apropriado, afinal, eu vou usar projeção ortográfica, mas deixei assim mesmo. São as configurações da câmera e dos planos de corte, para que eu monte as matrizes de transformação;
Capítulo 7 - Framework de Física e Renderização — 199
• ConfigBox2D: configurações gerais do controle de física do Game; • Objetos: lista de GameObjects que serão criados nesta cena; O ConfigFrustum contém: • Câmera: coordenadas da posição da câmera (tridimensionais); • Cima: coordenadas do vetor que aponta a direção de “cima”; • Direção: coordenadas do vetor que aponta a direção do olhar; • Perto: plano de corte mais perto da câmera; • Longe: plano de corte mais distante; O ConfigBox2D contém: • Gravidade: vetor de direção da força de gravidade (bidimensional). Se você não vai usar, basta zerar; • Sleep: para economizar ciclo de processamento, evitando fazer cálculos para objetos em repouso; • Velocity Interations: quantas interações dos cálculos de velocidade devem ser realizadas; • Position Interations: quantas interações dos cálculos de posição devem ser realizadas; • Proporção Metro / Tela: este é o “pulo do gato”, que vamos explicar mais adiante. É quanto vale 1 metro do Box2D em pixels da diagonal da tela; Agora, vamos ver o que um GameObject contém: • Id: identificador do GameObject (não pode ser repetido); • Tipo: o tipo Box2D deste objeto: 1 – estático, 2 – dinâmico e 3 – cinemático; • Forma: a forma do colisor do objeto: 1 – círculo, 2 – retângulo; • Alinhamento: se este objeto OpenGL está alinhado a alguma posição da tela: 0 – Nenhum, 1 – Alinhado ao “chão” (parte inferior da tela), 2 – Alinhado à esquerda, 3 – Alinhado à direita, 4 – Alinhado ao “teto” (parte superior da tela); • Centro: coordenadas da localização do centro do objeto, dentro do mundo Box2D; • Altura: altura do objeto em metros (Box2D). Lembre-se de manter objetos dinâmicos até 10 metros. Use a proporção metro / tela para criar escalas dos objetos; • Esticar Altura: se é para “esticar” a altura do objeto (do centro para cima e para baixo); • Largura: largura do objeto em metros (Box2D); • Esticar Largura: se é para “esticar” a largura do objeto (do centro para a esquerda e para a direita);
200 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
• Arquivo Textura: o nome da textura a ser carregada pelo OpenGL. Se você não informar, deve mander os tags, neste caso, o OpenGL não vai renderizar o objeto, servindo apenas para cálculos de física; • Densidade: a densidade (Box2D) do objeto; • CoefRetribuição: o coeficiente de retribuição (Box2D) do objeto; • Atrito: o atrito do material do objeto (Box2D); Agora, abra o exemplo: “..\Codigo\OpenGLAndroid\openglavancado.zip” e abra o arquivo: “assets/modelo/modeloExemplo.xml”, analise o modelo de game que eu criei. Para resumir, eu crio uma cena com o mesmo fundo (capim) que usei antes, uma bola, com textura, e quatro retângulos: teto, chão, parede esquerda e parede direita.
Proporcionalidade dos GameObjects Um dos maiores problemas de games móveis é ajustar as imagens às proporções da tela. É claro que você pode criar imagens com DPIs e tamanhos diferentes, tanto no Android como no iOS, porém, eu acho essa solução muito “tosca” para games. As texturas serão criadas a partir de imagens e serão redimensionadas de acordo. Então, podemos criar uma técnica para que a imagem de um GO seja sempre proporcional ao tamanho da tela. Já fizemos uma tentativa simples nos exemplos do capítulo anterior e agora é o momento de refinarmos esta técnica. Podemos pensar em uma tela tamanho padrão Android (Baseline), que também serve para iOS, com 320 x 480 pixels. Calculamos quanto vale 1 metro, em pixels, nessa proporção. Veja a próxima figura.
Ilustração 70: Proporção com relação à tela padrão
O valor de “x” é a diagonal da tela, que vale (arredondando) 577. Na imagem, temos uma bola com diâmetro é “a”. Neste caso, para que a imagem da
Capítulo 7 - Framework de Física e Renderização — 201
bola seja renderizada com o mesmo tamanho aparente em qualquer tamanho de tela, temos que calcular o quantas vezes “x” é maior que “a”. Para facilitar, vamos imaginar que “a” signifique 1 metro de medidas do Box2D, então, teremos uma referência para a proporção correta da imagem. Por exemplo: 1 metro = 5 pontos da tela. Vamos ver um exemplo prático. Digamos que eu queira que uma bola com, 1 metro de diâmetro, ocupe 1/5 da diagonal da tela, então, os cálculos seriam: • x: diagonal da tela; • a: tamanho de 1 metro em pixels; • p: proporção metro tela; • x / a = c, logo: a = x / c; Supondo que a tela tenha 320 x 480 pixels, então a diagonal vale aproximadamente 577, como queremos p = 5, então: a = 577 / 5 = 115,40 pixels Nossa bola teria uma diagonal de 115,40 pixels. Se passarmos para uma tela de iPad 2, com 768 x 1024 pixels, mantendo a mesma proporção, teríamos: a = 1280 / 5 = 256 pixels Nós informamos a proporção de 1 metro, com relação à diagonal da tela, na propriedade “proporcaoMetroTela”, do tag “”, dentro da cena.
Coordenadas e VBOs Sistemas de coordenadas Temos que entender como funcionará a integração entre as coordenadas Box2D, OpenGL ES e pixels da tela. Para começar, eu optei por usar um plano cartesiano com origem no centro da tela, e o valor das ordenadas na posição correta (aumentando para valores acima de zero). O Box2D usa um plano de coordenadas semelhante, logo, posso também utilizar a mesma orientação para o OpenGL, lembrando de mudar a matriz de projeção para que fique adequada. Orientação da tela Antes de mais nada, deixe-me falar sobre a orientação da tela. Em games de ação, geralmente a orientação da tela é “landscape”, ou seja: deitada. Só que a tela pode ficar “deitada” de duas maneiras: esquerda ou direita (posição dos botões no dispositivo). Mudar a orientação pode ser problemático, se o seu game prevê que os objetos se movam em três graus de liberdade (movimento livre 2D), pois ao mudar a orientação, você muda a posição dos objetos que estão alinhados, tendo que refazer toda esta parte.
202 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Para simplificar as coisas, eu fixo uma orientação e pronto! O game só pode ser utilizado naquela orientação. E não sou só eu que faço isto. Porém, se você quiser dar a liberdade de mudar a orientação (girar o aparelho), é só recalcular sua projeção e seus objetos dependentes. Matriz de projeção ortográfica Eu vou usar uma matriz de projeção como a da figura seguinte.
Ilustração 71: A matriz de projeção
Eu sei. A figura é confusa. Eu tentei várias maneiras sem sucesso. Mas vou tentar explicar: eu tenho uma tela com determinada altura e largura, em pixels. O game vai ficar muito mais fácil se eu trabalhar minha projeção OpenGL de acordo com o tamanho da tela, logo, as coordenadas da minha projeção devem ser relativas a ela. Eis o comando de criação da Matriz, tanto em Android, como em iOS: Android: Matrix.orthoM(matrizProjecao, Matrix.orthoM(matrizProjecao, Matrix.orthoM(matrizProjecao, Matrix.orthoM(matrizProjecao, Matrix.orthoM(matrizProjecao,
0, -width / 2, 0, width / 2, 0, -height / 2, 0, height / 2, 0, perto, longe);
iOS: iOS: matrizProjecao = GLKMatrix4MakeOrtho(-larguraViewPort / 2, larguraViewPort / 2, -alturaViewPort / 2, alturaViewPort / 2, perto, longe);
Capítulo 7 - Framework de Física e Renderização — 203
Os planos “perto” e “longe” são as coordenadas de “z” que determinam o que deve ser cortado da visão. Mantive os valores 1 e 10 respectivamente. Os planos “esquerda” e “direita” são, respectivamente, a metade esquerda da tela e a metade direita. Se sua tela tiver 320 x 480 pixels, na posição landscape, terá: -240 como limite da esquerda e 240 como o da direita. Os planos “cima” e “baixo” também são, respectivamente, a metade superior e inferior da tela. Supondo o mesmo exemplo (320 x 480), o limite superior será 160 e o inferior -160. Isto também determina a origem das coordenadas (0,0) e a orientação do eixo das ordenadas (“y”). A origem será no centro da tela, se expandindo 160 pixels para cima e para baixo, e 240 pixels para a esquerda e para a direita. Transformação de coordenadas Eu tenho que transformar a posição dos centros e o tamanho dos objetos de coordenadas Box2D para OpenGL, e isto é feito através da “Proporção Metro / Tela”, que já expliquei. Neste exemplo, estou utilizando o valor 7,0, ou seja, 1 metro vale 1/7 da diagonal da tela em pixels. Para saber quantos pixels o metro vale, é só dividir a diagonal por 7 e usar este valor como fator de escala. Cada coordenada de centro de GameObject, além das alturas e larguras, devem ser multiplicadas por este fator de escala ANTES da renderização. Assim, transformamos coordenadas Box2D em coordenadas OpenGL. Porém, em certos casos, há necessidade de transformar coordenadas OpenGL em Box2D (toque e alinhamento de objetos). Neste caso, é só dividir. Vertex Buffer Objects O VBO de textura é sempre fixo, ou seja, a textura cobre todo o retângulo, logo, eu gero um só VBO de textura e o utilizo sempre que for renderizar algum objeto. O VBO de vértices é calculado para cada GameObject que tenha textura, logo no início do programa. Como eles não variam, eu os carrego de forma estática, gerando um buffer remoto (para a GPU). Eles são calculados a partir do centro, da largura e da altura do objeto, sempre usando as coordenadas do mundo Box2D, multiplicadas pelo fator de escala. No Android, o método “protected void carregarCena(int i)” carrega o gameModel no OpenGL, e no iOS, o método “- (void)carregarCena: (int) numero” faz o mesmo. Primeiro, eles carregam as texturas, depois os vértices. Talvez, você esteja se perguntando: “Se as coordenadas dos vértices são fixas, como eu farei para mover e girar a bola?” Boa pergunta! Aí entrará a
204 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
matriz de modelo! Nos outros exemplos, nossa matriz modelo era sempre a identidade, pois não movíamos os objetos.
As texturas são carregadas das mesma maneira que fizemos nos outros exemplos.
Movimento e rotação de objetos As coordenadas dos vértices são sempre fixas, pois no meu exemplo, só interessa renderizar se estiverem dentro da projeção. Eu simplesmente faço uma translação e rotação das coordenadas, de acordo com o que o Box2D me disser. Neste exemplo, a “bola” nunca sairá da tela, pois está cercada por quatro paredes (teto, chão, esquerda e direita), que eu criei como objetos estáticos no meu modelo, alinhando cada uma ao seu limite correspondente na tela. Android: Matrix.setIdentityM(matrizModelo, 0); float posicaoX = go.getB2dBody().getTransform().position.x * proporcaoMetroTela; float posicaoY = go.getB2dBody().getTransform().position.y * proporcaoMetroTela; Matrix.translateM(matrizModelo, 0, posicaoX, posicaoY, 0); Matrix.rotateM(matrizModelo, 0, (float) (go.getB2dBody().getAngle() * 57.2957795), 0, 0, 1); Matrix.multiplyMM(matrizIntermediaria, 0, matrizCamera, 0, matrizModelo, 0); Matrix.multiplyMM(matrizIntermediaria, 0, matrizProjecao, 0, matrizIntermediaria, 0);
Eu obtenho o valor atual da posição do objeto Box2D que corresponde ao GameObject que estou trabalhando, uso o método “translateM”, que cria uma matriz de translação, para reposicionar o centro na posição indicada. Finalmente, altero a matriz de modelo para incluir o ângulo de rotação (no Android deve ser informado em Graus). iOS: GLKMatrix4 matrizModelo = GLKMatrix4Identity; float posicaoX = go.b2dBody->GetPosition().x * proporcaoMetroTela; float posicaoY = go.b2dBody->GetPosition().y * proporcaoMetroTela;
Capítulo 7 - Framework de Física e Renderização — 205 matrizModelo = GLKMatrix4Translate(matrizModelo, posicaoX, posicaoY, 0.0f); matrizModelo = GLKMatrix4Rotate(matrizModelo, go.b2dBody>GetAngle(), 0, 0, 1); GLKMatrix4 matrizIntermediaria = GLKMatrix4Multiply(m atrizCamera, matrizModelo); self.effect.transform.modelviewMatrix = matrizIntermediaria; self.effect.transform.projectionMatrix = matrizProjecao;
É basicamente o mesmo processo, considerando as diferenças: estou usando o Box2D C++, logo, a sintaxe deve ser levada em conta. A maior diferença é que a função GLKMatrix4Rotate recebe o ângulo em radianos. Depois, eu associo o produto das matrizes de câmera e modelo à propriedade “modelViewMatrix”, da instância de GLKBaseEffect. Por último, associo a matriz de projeção.
Atualização do mundo Box2D E como o Box2D altera as coordenadas? No Android, eu crio um Thread separado para atualizar o modelo, deixando o Loop de renderização em seu próprio Thread. Este Thread do Game Loop invoca o método “update”, no qual eu comando a atualização do “mundo” Box2D: protected void update() { /* * Atualiza o mundo Box2D e calcula a projeção */ synchronized(world) { world.step(1.0f / cenaCorrente.getFps(), cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations()); } }
É por esta razão que estou sincronizando o acesso ao mundo “Box2D”. Assim, evito “race conditions”. No iOS, eu poderia ter criado um Thread separado também, mas usei o próprio “Thread” de renderização, já que a classe GLKViewController oferece o método delegate “update”:
206 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS #pragma mark - GLKView and GLKViewController delegate methods - (void)update { @synchronized(self) { world->Step(1.0f / cenaCorrente.fps, cenaCorrente.box2d.velocityInterations, cenaCorrente.box2d.positionInterations); } }
Na verdade, por enquanto, a diretiva “@synchronized” está aqui só por enfeite. Futuramente, quando eu criar um Thread separado para o Game Loop, ela será necessária.
Renderização do modelo Como já vimos, no Android a classe “GLSurfaceView.Renderer” tem o método “onDrawFrame()”, invocado sempre que for necessário atualizar a view. Neste caso, eu renderizo primeiro a textura de fundo, utilizando um truque para que o fundo cubra toda a tela: 1. Faço as matrizes de modelo, câmera e projeção iguais à matriz identidade; 2. Uso um VBO com coordenadas baseadas na unidade: (-1,-1), (-1,1), (1, -1) e (1,1); 3. Desenho o fundo; Assim, a imagem cobrirá a tela toda. Depois, eu renderizo cada GameObject que tenha textura: 1. Indico o identificador da textura que vou usar; 2. Informo qual é o vetor de vértices que vou usar. O identificador do VBO foi carregado na propriedade “VBOVertices”, do GameObject; 3. Informo o MESMO vetor de textura (não há variação); 4. Ajusto a posição atual do centro do objeto, obtida do Box2D, na matriz de modelo; 5. Ajusto o ângulo atual do objeto, obtido do Box2D, na matriz de modelo; 6. Multiplico a matriz de modelo pela de câmera e a de projeção; 7. Desenho a textura do GameObject. Eu sugiro que você veja os dois exemplos, Android e iOS, identificando estas etapas no código.
Capítulo 7 - Framework de Física e Renderização — 207
• Exemplo Android: “..\Codigo\OpenGLAndroid\openglavancado.zip”; • Exemplo iOS: ““..\Codigo\OpenGLiOS\openGLIOSlavancado.zip””
Mipmaps Um dos problemas mais irritantes que existe é o “aliasing”, um efeito causado pela captura (amostragem) ou renderização de um sinal, seja ele de áudio ou de vídeo. Seu efeito prático é o “serrilhamento” da imagem. Observe bem as duas próximas figuras.
Ilustração 72: Imagem com “aliasing”
Ilustração 73: Imagem sem “aliasing”, usando Mipmaps
208 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Note como na primeira imagem, tanto a “bola” como o cenário, apresentam algum “serrilhamento”. Na segunda imagem, as duas figuras aparecem perfeitas. O “aliasing” acontece quando reduzimos uma imagem, porém existem técnicas para suavizar o efeito. Basicamente, podemos desfocar as pontas de uma imagem, dando a impressão de que ela está contínua. A criação de “Mipmaps” é uma das técnicas para isto. Basicamente, são criadas várias versões da mesma imagem, com tamanho e nível de detalhe diferente, armazenadas em conjunto. No momento de renderizar a imagem, é selecionada aquela cujo tamanho mais se aproxima do tamanho a ser utilizado. Mipmaps podem ser gerados manualmente ou automaticamente, porém ocupam maior memória. Estima-se que o uso de Mipmaps aumente em 1/3 o tamanho necessário para armazenar uma única textura. No OpenGL ES nós temos a opção de solicitar a criação dos Mipmaps com a opção: • GL_TEXTURE_MIN_FILTER = GL_LINEAR_MIPMAP_NEAREST Android: GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, hTextura); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR_MIPMAP_NEAREST); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT); GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT); GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, imagem, 0);
iOS: NSDictionary * options = [NSDictionary dictionaryWithObjectsAndKeys: [NSNumber numberWithBool:NO], GLKTextureLoaderOriginBottomLeft, [NSNumber numberWithBool:YES], GLKTextureLoaderGenerateMipmaps, nil];
Capítulo 7 - Framework de Física e Renderização — 209 NSError * error; NSString *path = [[NSBundle mainBundle] pathForResource:nome ofType:nil]; props.textureInfo = [GLKTextureLoader textureWithContentsOfFile:path options:options error:&error]; Se eu fosse você, sempre utilizaria imagens cujas dimensões fossem potências de 2 (POT). O OpenGL ES 2.0 exige isso se for mandar gerar Mipmaps.
Uso do Framework No Android Mais uma vez, os arquivos de exemplo Android estão em: “\Codigo\OpenGLAndroid\openglavancado.zip”. Eu criei este framework para agilizar o desenvolvimento, promovendo o reuso dos componentes. No exemplo Android, temos os pacotes: • com.obomprogramador.games.exemplo; • com.obomprogramador.games.openglavancado; • com.obomprogramador.games.xmloader; Você só precisa escrever as classes do pacote “com.obomprogramador.games.exemplo”, que são: • Activity do game; • Renderer do game (derivado de: “OpenGLAvancadoRenderer”); Para começar, crie seu XML de modelo do game, e coloque na pasta “assets/modelo”. Abra o arquivo de XML que eu criei para ver um exemplo. Depois, crie uma Activity para o seu Game. Nela, você deverá: 1. Instanciar o Renderer que será utilizado pela GLSurfaceView; 2. Instanciar a classe “OpenGLAvancadoView”, passando o Contexto de aplicação e a instância do Renderer para ela; Eis o exemplo que eu criei (“MainActivity.java”): public class MainActivity OnTouchListener {
extends
Activity
implements
protected OpenGLAvancadoView mView; protected OpenGLAvancadoRenderer renderer; @Override protected void onCreate(Bundle icicle) { super.onCreate(icicle);
210 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS try { renderer = new Renderer(this. getApplicationContext()); mView = new OpenGLAvancadoView(getApplicat ion(), renderer); } catch (Exception e) { Log.d(“GAMEMODEL”, “Exception: “ + e.getMessage()); } this.requestWindowFeature(Window.FEATURE_NO_ TITLE); this.getWindow().setFlags(WindowManager.LayoutParams. FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); mView.setOnTouchListener(this); setContentView(mView); } @Override protected void onPause() { super.onPause(); mView.onPause(); } @Override protected void onResume() { super.onResume(); mView.onResume(); } @Override public void onConfigurationChanged(Configuration newConfig) { newConfig.orientation = Configuration.ORIENTATION_ LANDSCAPE; super.onConfigurationChanged(newConfig); } @Override public boolean onTouch(View arg0, MotionEvent arg1) { mView.getRenderer().toque(); return true; } }
Capítulo 7 - Framework de Física e Renderização — 211
Finalmente, você terá que criar uma subclasse de “OpenGLAvancadoRenderer”. Nela, você poderá tratar eventos, como o toque na tela, por exemplo, ou então sobrescrever alguns métodos do Renderer. Eis o meu exemplo: public class Renderer extends OpenGLAvancadoRenderer { public Renderer(Context context) throws Exception { super(context); } @Override public void toque() { super.toque(); if (simulando) { synchronized(world) { // Comanda a aplicação de forças GameObject go = new GameObject(); go.setId(1); int inx = cenaCorrente.getObjetos(). indexOf(go); go = cenaCorrente.getObjetos(). get(inx); Body bola = go.getB2dBody(); Vec2 forca = new Vec2(50.0f * bola.getMass(), 50.0f * bola.getMass()); Vec2 posicao = bola.getWorldCenter(). add(new Vec2 (0,3)); bola.setAwake(true); bola.applyForce(forca, posicao); } } } }
No meu caso, eu apenas aplico uma força à bola. No iOS No iOS, você pode fazer a mesma coisa, criando subclasses do seu ViewController.
212 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Conclusão Combinando OpenGL ES e Box2D, criamos um game com boa sensação de realidade, pois os efeitos de movimento e colisão são aceitáveis e, também, com bom desempenho gráfico, evitando lags e outros efeitos indesejáveis. Mas a principal vantagem do uso do OpenGL ES - e eu volto a insistir nisto - é a padronização. Criar um framework comum para plataformas tão distintas, como Android e iOS, seria muito mais difícil se utilizássemos os mecanismos gráficos nativos. Note como foi possível abstrair as propriedades e torná-las parametrizáveis. Mas funciona mesmo? Se você é adepto ou adepta de São Tomé, veja as próximas figuras, que mostram, respectivamente, o exemplo sendo executado em um dispositivo Android (Smartphone LG p500, com Cyanogen Mod 7, versão Android: 2.3.4), e em um dispositivo iOS (iPad com iOS 6.0.1).
Ilustração 74: Execução em dispositivo Android
Ilustração 75: Execução em dispositivo iOS
Capítulo 7 - Framework de Física e Renderização — 213
Como você leu no capítulo, nenhum objeto é fixo dentro do código-fonte, pois todas as propriedades, tanto de Box2D como de OpenGL, são carregadas a partir do arquivo XML. Você pode notar também que o tamanho da bola é proporcional à tela, mesmo em casos de geometrias diferentes. E, como usei Mipmaps, a imagem não aparece serrilhada, mesmo em tamanho menor. Estude e execute os exemplos Este livro apresenta melhor resultado se você, leitor ou leitora, analisar e executar os exemplos. Tente mudar, acrescentar outros GameObjects, implementar uma lógica de jogo, sei lá. Quer algumas sugestões: 1. Crie “alvos” nas paredes e tente acertá-los com a bola; 2. Implemente “chute” direcional; 3. Crie um exemplo com Joints! Que tal criar um “ragdoll”, ou seja, aqueles bonecos que caem de escadas e se quebram todos? Estude bem o exemplo deste capítulo, pois vou utilizá-lo com base para todos os outros. Otimizações Sabe uma coisa que eu odeio? Quando eu mostro um trabalho para alguém e a primeira coisa que a pessoa faz é criticar. Todos adoram criticar, mas poucos se dispõem a colaborar. Eu acho que a inveja é a maior causa deste problema. É claro que eu não sou perfeito, logo, minhas criações estão longe disso, mas o que essas pessoas sequer tentam entender, antes de dispararem suas críticas, é o contexto no qual o trabalho foi feito. Poucas pessoas consideram o seu nível de conhecimento, o trabalho que você teve, ou mesmo a beleza das soluções que você deu. Preferem se apegar a pequenos detalhes, percebidos por quem não fez nada para ajudar. Ao invés de criticar o trabalho logo de cara, poderiam dar sugestões de melhorias, por exemplo: “ficou muito bom e você pode melhorar ainda mais se blá-blá-blá”. Não se iluda! Você receberá muitas críticas deste tipo, porém, não se deixe abater e procure analisar com frieza o que o seu algoz está dizendo. Pode ser uma crítica com fundamento e pode ser até razoável levá-la em consideração. Por que disso? Bem, é claro que é possível melhorar tudo o que mostrei até agora, especialmente as técnicas de uso do OpenGL ES. Na verdade, OpenGL é um enorme universo, cuja superfície nós sequer arranhamos. Quer fazer uma experiência? Entre no “Stack Overflow” e procure o tag “OpenGL ES 2.0” (http://stackoverflow.com/questions/tagged/opengl-es-2.0). Você verá que as pessoas têm dúvidas sobre assuntos que você sequer imaginava que existiam!
214 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Porém, dá para otimizar o uso do OpenGL, embora eu já tenha otimizado alguma coisa ao usar VBOs estáticos para vértices e textura. Porém, tenha em mente o que o professor Knuth disse sobre otimização: “Premature optimization is the root of all evil” “Otimização prematura é a raiz de todo o mal” Professor Dr. Donald Knuth (http://en.wikipedia.org/wiki/Donald_Knuth) O que isto quer dizer? Simplesmente que você deve se concentrar em terminar o trabalho ANTES de começar a otimizar. Existem muitas atitudes simples, que podem resultar em ganhos significativos. Muitas vezes, os programadores perdem tempo tentando melhorar a performance em milhonésimos de segundos, sem se perguntar se isto é realmente necessário. Eu procurei mostrar a aplicação de OpenGL ES de maneira simples e prática, com um mínimo de otimização. Eu penso que você pode e deve otimizar seu game. Sugiro algumas referências importantes para isto: 1. http://gamedev.stackexchange.com; 2. Stack Overflow (http://http://stackoverflow.com); 3. Guia de ajustes do OpenGL ES no iOS (http://developer.apple. com/library/ios/#documentation/3DDrawing/Conceptual/OpenGLES_ ProgrammingGuide/Performance/Performance.html#//apple_ref/doc/
uid/TP40008793-CH105);
Capítulo 8 Técnicas Comuns em Games Existem muitos problemas interessantes em games, para os quais existem técnicas simples que proporcionam soluções razoáveis. Aliás, eu sugiro que você sempre parta de soluções simples para os seus problemas de desenvolvimento de games. Não tente implementar técnicas sofisticadas logo de cara. E é claro que existem várias outras maneiras de fazer o que eu vou mostrar aqui, porém, vou tentar fazer da maneira mais simples e funcional possível, de modo a dar uma base para que você possa melhorar.
Como exibir um HUD Os games usam e abusam de indicadores, conhecidos como “HUDs” (“Heads-up displays”), ou mostradores que “flutuam” sobre as imagens do jogo. Há vários tipos de informações que podemos dar ao jogador, por exemplo: • Hit points: pode ser numérico ou gráfico, indicando quantos “tiros” o jogador tomou ou quanto pode aguentar; • Life bar: uma barra que indica a quantidade de “vida” que o jogador tem ou, então, sua força (“stamina”). Pode ser uma barra contínua ou formada por pequenas imagens, como corações, por exemplo; • Recharge bar: uma barra que indica quanto falta para recarregar algum poder, por exemplo: “nitro”, em jogos de carros, ou escudo, em jogos de tiros; • Cronômetro: informação sobre o tempo decorrido ou tempo restante; • Capacidades: itens que podem ser usados pelo jogador, como: armas, por exemplo; • Controles: itens que servem para controlar o movimento e as ações do jogador, como: andar, atirar etc. Em games móveis, isto pode ser substituído por: toque, arrasto ou mesmo inclinação do dispositivo; • Menus ou botões de ação: servem para invocar outras funções do game, como: ajuda, opções, sair do game etc; • Pontos e objetivos: quantos pontos o jogador fez e qual é a sua posição com relação aos objetivos a serem alcançados. Alguns games incluem “Goal Bars”, que funcionam como as Life Bars, só que mostram os objetivos alcançados.
216 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Nos jogos mais modernos, existe a tendência de diminuir o número de informações HUD, substituindo-as por efeitos visuais nos GameObjects. Por exemplo, ao invés de representar a “Lifebar”, podemos fazer o personagem apresentar machucados ou cansaço. De qualquer forma, você vai necessitar posicionar algumas informações na tela, as quais não representam GameObjects, logo, não estão sujeitas às leis físicas do game. Na figura seguinte, mostramos a tela do jogo “Bueiro Ball”, um projeto do meu livro anterior, Mobile Game Jam (http://www.mobilegamejam.com/).
Ilustração 76: O HUD do game “Bueiro Ball”
Minimize as informações a serem exibidas A ideia de HUD é que o jogador não tenha que se desviar muito do jogo para saber informações importantes. Ficar mostrando mensagens textuais, usar indicadores complexos ou “poluir” a cena com muitos indicadores pode prejudicar a jogabilidade. Como eu mencionei anteriormente, a tendência atual é minimalista e você pode dar ao jogador a opção de configurar o que ele quer ver no HUD. O OpenGL ES não possui função para renderizar texto Como assim? Eu preciso fazer algo como “glDrawText()”! Sinto muito, mas ele não possui esta característica. Bem, se o OpenGL não renderiza texto, como fazemos para exibir indicadores? Existem algumas opções: 1. Use indicadores gráficos, baseados em texturas simples, como “Lifebars” e “Goalbars”. Como são texturas simples, podemos controlar quando serão desenhadas e qual o seu tamanho; 2. Para textos, use outras telas. Como veremos mais adiante, é possível integrar telas OpenGL ES com outras telas, formando um fluxo (ou
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 217
“Storyboard”) do jogo. E isto é possivel tanto com Android como com iOS; 3. Use texturas para representar mensagens fixas e rótulos de informações; 4. Use “Bitmap fonts” para escrever pequenos campos de texto ou números. Basicamente consiste em criar texturas de cada caractere (ou uma Sprite Sheet do alfabeto inteiro) e renderizar conforme o caractere ascii desejado; Seja qual for a abordagem que você vai usar, lembre-se disto: evite poluir a cena do jogo! Mensagens textuais atrapalham (mais do que ajudam) o jogador. Se você precisa mostrar um texto longo, crie uma textura e mostre ou, então, desvie para outra tela. Em alguns casos, é possível utilizar diálogos (“Toasts”). Mas evite tentar renderizar um longo texto usando “Bitmap fonts”. Se você quiser mesmo representar frases dinamicamente, ou seja, a partir de um “String”, então terá que criar texturas de cada letra, número ou caracter especial. Você pode, opcionalmente, criar uma “Sprite Sheet” com todos os caracteres de determinada fonte, usando as coordenadas de textura para indicar qual será apresentada. Para isto, existem até programas que criam Bitmap fonts a partir de fontes disponíveis no sistema, como “True type”, por exemplo. Um dos mais interessantes é o “Bitmap Font Generator”, do site Angel Code (http://www.angelcode.com/products/bmfont/). Ele gera sprite sheets com os caracteres da fonte selecionada, além de um arquivo (pode ser em XML) com os detalhes de cada caractere, como: altura, largura, espaçamento etc. Existem técnicas para ler informações sobre Bitmap fonts e renderizar caracteres dinamicamente, respeitando o espacejamento proporcional das letras e números (kerning). Eu recomendo os seguintes sites: • http://www.gamedev.net/topic/330742-quick-tutorial-variable-width-bitmap-fonts/, que usa o BMFont do Angel Code; • O excelente tutorial do site NeHe: http://nehe.gamedev.net/tutorial/ bitmap_fonts/17002/; • Outro excelente tutorial do Wikibooks: http://en.wikibooks.org/wiki/ OpenGL_Programming/Modern_OpenGL_Tutorial_Text_Rendering_01;
Com estes tutoriais, você será capaz de exibir textos de maneira correta, com o espacejamento proporcional adequado, exatamente como em um editor de textos. Eu, por outro lado, não acredito que esta seja a forma mais eficiente de implementar um HUD. O jogador está pouco ligando se você usou o espacejamento correto (kerning) ou não! O que ele quer é ver a informação relavante, de forma descomplicada.
218 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Uma solução simples Eu já usei algumas soluções que indiquei. Na verdade, eu gosto muito do BMFont do Angel Code e usei o tutorial deles para criar HUDs textuais com sucesso. Porém, eu gosto de seguir dois princípios em meus projetos de game: • Interface intuitiva; • Performance razoável; Uma interface intuitiva permite ao jogador se concentrar nos objetivos do game, pois as informações relevantes serão apresentadas, quando necessárias, de forma simples. E mensagens textuais não são muito intuitivas. Na verdade, o jogador acaba “decorando” o texto e só vê alguns caracteres. Com o tempo, ele pode se confundir, caso você mude ligeiramente a mensagem. O outro princípio é que o game deve ter uma performance razoável. E, como já vimos, devemos otimizar muito nosso uso do OpenGL ES. Entre as formas de otimização estão: • Utilizar VBOs (estáticos e na memória da GPU); • Minimizar a utilização de memória, tanto da CPU como da GPU; • Utilizar menos operações de desenho; Se você tem um alfabeto completo, só para renderizar algumas mensagens, provavelmente está disperdiçando recursos. Ninguém utiliza todas as letras para formar um HUD. E, caso seja uma mensagem longa, pode ser renderizada estaticamente, ou seja, você cria uma textura com o texto todo escrito nela. Logo, usar Bitmap fonts completas para criar mensagens textuais viola este princípio de performance razoável. Minha solução é simples: 1. Sempre que possível, uso indicadores na forma de ícones ou barras fixas; 2. Uso VBOs de vértices e de textura estáticos. Eu crio posições pré-determinadas da tela e crio VBOs para elas na GPU, além de usar sempre o mesmo VBO de textura; 3. Só exibo rótulos e mensagens absolutamente necessárias. Caso precise exibir uma mensagem mais longa, eu navego para outra tela; 4. Rótulos e textos estáticos são criados a partir de texturas; 5. Só uso textos dinâmicos quando necessário e, mesmo assim, limito apenas a números. Assim, só preciso criar texturas para os algarismos (0-9) e para alguns poucos sinais (“:”, “-” e “+”); Eu gosto de elaborar um “layout” para a tela do game, no qual crio posições fixas, nas quais os indicadores serão exibidos. Vamos pegar o exemplo do capítulo anterior e acrescentar dois indicadores: o rótulo “Tempo:” e a hora atual, no formato “hh:mm:ss”.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 219
Ilustração 77: O Layout dos indicadores
Para o rótulo “Tempo:” eu crio uma textura completa. Para isto, eu gosto de usar o programa de desenho do LibreOffice, mas você pode usar qualquer outro programa, como o Inkscape ou o Gimp.
Ilustração 78: Textura do rótulo
E o tempo em si será um texto dinâmico, formado por números e pelo sinal “:”. Então, eu crio texturas a partir destes elementos. Eu crio texturas separadas para cada algarismo e para o sinal “:”.
Ilustração 79: Exemplo de textura de número
220 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Lembre-se: crie as texturas POT, ou seja, com dimensões em potências de 2! Eu terei seis posições na tela: Tempo: Hh : mm : ss E terei que calcular vetores de vértices para cada uma delas, criando VBOs na GPU, além de enviar as texturas separadamente. Porém, as coordenadas de textura são iguais (eu cubro os quatro vértices de maneira igual). Com os VBOs e as texturas na GPU, para desenhar é só informar qual é a textura, qual o VBO de vértices e qual o VBO de textura. Para o rótulo é mais simples, pois eu já sei qual textura devo usar. É só guardar o indicador (handler) de textura. Para os outros campos, eu preciso transformar os valores em “strings” e pegar os caracteres ASCII de cada posição, buscando as texturas correspondentes.
Aumentando o framework de game Vamos fazer um pequeno exemplo e aproveitar para “turbinar” nosso framework de game. Os exemplos deste tópico estão em: • Android: “..\Codigo\OpenGLAndroid\openglcomtexto.zip”; • iOS: “..\Codigo\OpenGLiOS\OpenGLComTexto.zip”; Na figura seguinte, podemos ver o resultado da execução destes exemplos.
Ilustração 80: Cena com HUD
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 221
Bem, como eu desenharei texto frequentemente, resolvi aumentar o framework do capítulo anterior para implementar a renderização baseada em “layout”. Para isto, criei um modelo de tela em XML, que descreve as posições fixas na tela e as texturas. No exemplo Android, o arquivo fica em: “assets/texturas/modelotela.xml”, e, no iOS, fica dentro de “Supporting files”. Abra o arquivo e veja como eu defini as posições e texturas. Definindo posições As posições são definidas dentro do tag “posicoes”: 1 10 30 0 0 2 10 6 0 30 ...
Cada posição tem as seguintes propriedades: • id: número identificador da posição. Deve ser único; • altura: altura da posição; • largura: largura da posição; • topo: coordenada “y” da posição; • esquerda: coordenada “x” da posição; Antes de mais nada, deixe-me explicar a unidade dos tamanhos e coordenadas. Lembra-se da diagonal da tela? Bem, eu segui o mesmo princípio. Só que, para facilitar, eu passei a usar percentuais da diagonal da tela. Por exemplo, as coordenadas topo e esquerda especificam percentuais da diagonal da
222 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
tela, que deverão ser convertidos em pixels pelo renderizador, para calcular o VBO da posição. Da mesma forma, a altura e largura também são especificadas através de percentuais da diagonal da tela. Utilizando a unidade baseada em percentual da diagonal da tela, eu mantenho a proporção correta dos elementos de layout em telas de tamanho diferente. Definindo texturas As posições apenas definem VBOs de vértices. Eu posso determinar quais texturas serão mapeadas em quais posições. Para isto, preciso saber quais texturas eu quero ter no meu HUD, logo, após definir posições, eu defino as texturas: ... 1 0 tempolabel.png true false 1 2 48 zero.png false false 0 ...
Cada textura tem as seguintes propriedades: • id: identificador da textura. Deve ser único dentro das texturas; • ascii: código ascii que corresponde à textura, se for um caractere, ou então zero, se for uma textura qualquer; • imagem: arquivo de imagem da textura (POT); • visivel: se a textura estará visível automaticamente;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 223
• clicavel: se a textura pode ser clicada (é para implementação futura!); • posicaoFixa: se a textura está associada a uma posição fixa, dentro das posições pré-definidas; Note que a textura cujo “id” é 1 não possui representação ASCII, logo, não é um caractere e já está definida na posição cujo “id” é 1 (os ids não necessitam ser iguais). O renderizador vai exibir esta textura automaticamente utilizando o VBO da posição 1. Já a textura 2 é um caracter ASCII, neste caso, o algarismo zero, e não possui posição fixa. Carga do modelo de tela Eu uso o mesmo mecanismo da carga do arquivo de modelo de game, tanto em Android como em iOS. No Android, a carga do XML é feita pelo método “carregarModeloTela()”, invocado pelo método “onSurfaceChanged()”, do Renderer (“OpenGLAvancadoRenderer.java”). No iOS, a carga é feita no método “- (void) carregarModeloTela”, invocado pelo método “recalcularAlinhamentos”, da classe “OGCTViewController”. No iOS, eu usei o mesmo framework para carregar o Modelo de Tela e o Modelo de Game, criado por Nick Farina (http://nfarina.com/post/2843708636/a-lightweight-xml-parser-for-ios). O NSXMLParser, nativo do iOS, é muito verboso, e o “SMXMLDocument”, do Nick Farina, é muito parecido com o parser do Android. A carga em si não tem nada demais. Ela simplesmente carrega o meu pequeno modelo de classes.
Ilustração 81: O modelo de dados do layout
224 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
A coisa começa a ficar interessante quando eu crio os VBOs das posições e carrego as texturas. No Android, isto é feito no método “loadXML()”, da classe “ModeloTelaLoader()”, invocado pelo método construtor da classe “OpenGLAvancadoRenderer”. No iOS, isto é feito no método “loadModeloTela”, da classe “OGMTModeloTelaLoader”, invocado pelo método “setupGL” da classe “OGCTViewController”. O procedimento para ambas as plataformas é o mesmo. Para cada posição carregada do XML: 1. Converto as coordenadas e dimensões de acordo com o valor percentual da diagonal da tela; 2. Calculo o centro da posição, utilizando os valores já convertidos; 3. Invoco o método que cria o VBO de vértices e retorna o identificador do buffer (handler). É o mesmo método que calcula os VBOs dos vértices dos GameObjects; Depois, para cada textura carregada do XML: 1. Crio o buffer de textura; 2. Transfiro para a memória da GPU; 3. Verifico se a textura é um código ASCII, então eu insiro uma ligação entre ela e o código ASCII no vetor associativo de caracteres; 4. Verifico se a textura ocupa uma posição fixa, neste caso, eu procuro a posição e implemento a associação entre as duas. Se a textura for um caractere ASCII, eu vou utilizá-la para escrever alguma coisa. Então, eu uso um vetor associativo (“HashMap” no Android, e “NSMutableDictionary” no iOS) para descobrir qual é a textura correspondente a um caracter ASCII. Se a textura ocupa uma posição fixa, então eu já associo a instância da posição. Renderização das texturas fixas Logo após renderizar a textura de fundo da cena, eu renderizo cada textura de posição fixa. No Android, isto é feito no método “onDrawFrame()”, da classe “OpenGLAvancadoRenderer”, e, no iOS, é feito no método “drawInRect” (delegado de “GLKView”), da classe “OGCTViewController”. O procedimento é o mesmo para as duas plataformas. Para cada textura carregada do XML: 1. Informamos ao OpenGL o seu identificador de textura; 2. Informamos ao OpenGL o VBO a ser utilizado, que é da posição que ela ocupa;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 225
3. Informamos ao OpenGL o mesmo VBO de texturas que usamos para todas as texturas do game; 4. Desenhamos; Renderização das texturas dinâmicas Uma textura dinâmica pode ser uma “Life bar” ou um texto formado por várias texturas. Neste exemplo, eu uso apenas texto, mas o raciocínio é o mesmo para todos os casos. No Android, isto é feito no método “onDrawFrame()”, da classe “OpenGLAvancadoRenderer”, e, no iOS, é feito no método “drawInRect” (delegado de “GLKView”), da classe “OGCTViewController”. Logo após renderizar as texturas de posição fixa, eu invoco um método para obter o texto dinâmico a ser renderizado. No Android, é o método “desenharTextos()”, da classe “OpenGLAvancadoRenderer”, e, no iOS, é o método “desenharTextos”, da classe “OGCTViewController”. Neste método, eu obtenho o valor da hora atual e o formato em “hh:mm:ss”, invocando o método que realmente renderiza os textos dinâmicos. O método que renderiza os textos dinâmicos (Android e iOS: “desenharString”), recebe o texto e a primeira posição na tela, a partir da qual as texturas devem ser renderizadas. Deve haver posições suficientes para renderizar TODOS os caracteres. Então, ele pega cada caractere ASCII do texto e procura no dicionário (Android: “HashMap”, iOS: “NSMutableDictionary”) qual é a textura correspondente. Então, ele seleciona a textura e usa o VBO da posição corrente para renderizá-la. Depois, ele avança para a próxima posição. Será que funciona? Claro que sim. E com uma vantagem: se eu quiser mudar o “game”, só terei que alterar em poucos locais: • Nos arquivos XML de modelo de game e de tela; • No método “desenharTexto”, tanto no Android, como no iOS; Note que, neste exemplo, a “bola” está na frente do HUD. Foi proposital, pois eu acho que fica melhor assim. Se você quiser, pode posicionar o HUD na frente da “bola”, mudando a posição de renderização das texturas fixas e dinâmicas para depois dos GameObjects. Mais uma vez, para os adeptos de São Tomé, vou mostrar a solução sendo executada nas duas plataformas: um Smartphone Android LG p500 e um iPad.
226 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 82: Versão com HUD executando no Android
Ilustração 83: Versão com HUD executando no iOS
Integrando o game em uma aplicação móvel Você pode criar seu game todo em uma única janela OpenGL ES, tanto no Android, como no iOS. Você pode variar os mapas de tela, criando versões diferentes e alternando conforme o jogador comandar. Só que estará replicando
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 227
o funcionamento da interface gráfica dos dispositivos, o que é um desperdício, afinal, Android e iOS possuem excelentes recursos de navegação, com os quais os usuários já estão acostumados. Além disto, diante da dificuldade para renderizar textos, podemos criar telas informativas ou de ajuda, ou mesmo de configuração, de maneira simples e rápida. Se você quiser, poderá integrar a tela e o controlador do seu game ao fluxo de uma aplicação, o que incluirá telas convencionais, telas OpenGL ES e controles de navegação. Vamos fazer um exemplo bem simples. Ele será baseado no exemplo anterior (com HUD) e funcionará desta forma: 1. Ao executar a aplicação, uma tela convencional do dispositivo será exibida (Activity no Android, UIView no iOS), com um link para o Game; 2. Ao clicar no link, a tela OpenGL ES do game é caregada e o jogo começa; 3. Haverá uma textura estática na tela OpenGL ES, com o rótulo “Sair”. Ao clicar nela, o game retorna para a tela anterior; Para implementar isto, vamos utilizar a propriedade “clicável”, que incluímos no modelo de tela para cada textura. Vou mostrar a imagem das duas telas no Android (no iOS é a mesma coisa).
Ilustração 84: A tela inicial (à esquerda), e a tela do Game (à direita)
Plataforma Android O código-fonte deste exemplo está em: “..\Codigo\OpenGLAndroid\appintegradadroid.zip”. Na plataforma Android é bem simples: 1. Criei uma subclasse de “Activity” e a registrei no arquivo “AndroidManifest.xml”, como a atividade principal; 2. Nesta Activity principal, criei uma “TextView” (com os atributos: “android:clicable = true” e “android:onclick = chamar”. Poderia ser um botão também, ou uma imagem;
228 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
3. Na Activity principal, criei um método para receber o clique e invocar a Activity OpenGL ES; 4. Modifiquei o arquivo “modelotela.xml” para incluir a textura do botão “sair” (arquivo “sair.png”); 5. Na Activity do game, eu intercepto o toque e invoco o método “toque”, no Renderer; 6. No Renderer, eu identifico se o toque foi dentro de alguma textura clicável. Se foi, eu comando a Activity do Game para se auto finalizar, o que ativará a Activity anterior (a que o usuário acionou); A Activity principal invoca a do Game no método “chamar()”: public void chamar(View view) { Intent i = new Intent (this.getApplicationContext(), GameActivity.class); this.startActivity(i); }
Na Activity do Game (GameActivity), eu modifiquei o método que interceta o toque (“onTouch()”): @Override public boolean onTouch(View arg0, MotionEvent arg1) { ((Renderer) mView.getRenderer()).toque(arg1); return true; }
Agora, eu invoco um método diferente no Renderer, que recebe também o “MotionEvent”, com as coordenadas do toque. Na classe “Renderer” (derivada de “OpenGLAvancadoRenderer”), eu deixei de sobrescrever o método “toque()” e criei outro, que recebe o “MotionEvent”: public void toque(MotionEvent eventoToque) { super.toque(); if (simulando) { synchronized(world) { if (!clicouEmTextura(eventoToque)) { // Comanda a aplicação de forças GameObject go = new GameObject(); go.setId(1); int inx = cenaCorrente.getObjetos().indexOf(go); go = cenaCorrente.getObjetos().get(inx);
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 229 Body bola = go.getB2dBody(); Vec2 forca = new Vec2(50.0f * bola.getMass(), Vec2 forca =50.0f * bola.getMass()); Vec2 posicao = bola.getWorldCenter().add( new Vec2 (0,3)); bola.setAwake(true); bola.applyForce(forca, posicao); } } } }
Se o toque foi fora da textura, então ele tem que aplicar uma força à bola, conforme já fazia no exemplo anterior. O método “clicouEmTextura” verifica se o clique foi dentro da textura selecionada: private boolean clicouEmTextura(MotionEvent eventoToque) { boolean resultado = false; Coordenada toque = new Coordenada ( eventoToque.getX(), eventoToque.getY(), 0.0f); for (Textura t : modeloTela.getTexturas()) { if (t.isClicavel()) { if (toqueDentro(t,toque)) { resultado = true; ((GameActivity)activity).sair(); } } } return resultado; }
Eu poderia verificar o “id” da textura, para associar à ação a ser tomada, porém, no meu caso, só há uma textura “clicável”: o rótulo sair. Para cada textura, eu verifico se ela é clicável, então eu invoco o método que verifica se o toque foi dentro dos limites da textura (VBO): private boolean toqueDentro(Textura t, Coordenada toque) { boolean resultado = false; PosicaoTela p = t.getPosicaoAtual(); float cTop = (alturaViewPort / 2) - ((p.getTopo() *
230 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS diagonalTela) / 100); float cLeft = - (larguraViewPort / 2) + ((p.getEsquerda() * diagonalTela) / 100); float cAltura = (p.getAltura() * diagonalTela) / 100; float cLargura = (p.getLargura() * diagonalTela) / 100; float cBottom = cTop - cAltura; float cRight = cLeft + cLargura; float toqueX = toque.getX() - (larguraViewPort / 2); float toqueY = (alturaViewPort / 2) - toque.getY(); if ((toqueX >= cLeft && toqueX <= cRight) && (toqueY >= cBottom && toqueY <= cTop)) { resultado = true; } return resultado; }
Talvez você esteja se perguntando: “porque ele não usou a classe “java. awt.Rectangle” ?” A resposta é que as classes de interface (“awt” no Android) costumam assumir que a origem das coordenadas é no canto superior esquerdo, e que o valor de “y” é “flipped”, ou seja, invertido. Então, como meu sistema de coordenadas é diferente (começa no meio da tela e o “y” é normal), eu criei uma função própria para verificar se o ponto está dentro do retângulo. Se estiver, então eu tenho que finalizar a minha Activity: ((GameActivity)activity).sair();
Eu não posso fazer isso dentro do Renderer, então eu criei navegação bidirecional entre a classe GameActivity e a classe Renderer, incluindo uma propriedade que aponta para a Activity. Na GameActivity, eu criei o método “sair()”: public void sair() { this.finish(); }
E pronto! Temos um “game” inserido em um fluxo normal de aplicação. É importante lembrar que somente a Activity pode fazer tarefas como: carregar outras activities, enviar emails ou encerrar a si mesma.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 231
Plataforma iOS O código-fonte deste exemplo está em: “..\Codigo\OpenGLiOS\AppIntegradaIOS.zip”. Apesar de parecer muito complexo, mais devido ao Xcode do que ao Objective C (na minha opinião), o processo é tão simples como criar qualquer aplicação com múltiplas “views”, baseada em Storyboard. A melhor maneira de começar é criar uma aplicação normal, com o template “Single View”, marcando as opções de usar “Storyboards” e ARC. Lembre-se de direcionar como “universal”, ou seja: pode ser executada em qualquer dispositivo iOS. Depois: 1. Adicione os frameworks: GLKit..framework e OpenGLES.framework; 2. Altere Header Search Path para: “${PROJECT_DIR}/**”; 3. Altere Compile Sources As para: “Objective C++”; 4. Adicione o Box2D do outro projeto (copie o folder evitando criar referências!); 5. Adicione as imagens e Arquivos XML (dentro de “supporting files”); 6. Crie um grupo e adicione as classes (arquivos “.h” e “,m”) do projeto OpenGLCom Textu. Não acrescente os outros arquivos, só as classes; 7. Nos dois Storyboards, crie um botão para invocar o game e adicione mais um GLKViewController, mudando a classe para “OGCTViewController”; 8. Nos dois Storyboards, crie uma “segue” do botão “Entrar no game” para a outra View (GLKView); Antes de mais nada, deixe-me repetir: estou assumindo que você sabe programar aplicações no iOS, logo, conhece “Storyboards” e “segues”. Se você não conhece, então recomendo meu livro anterior: “Mobile Game Jam” (http://www.mobilegamejam.com/). Eu estou usando uma segue modal. Se você quiser criar “segues” do tipo “push”, terá que criar um NavigationController. Com segues do tipo “push”, você pode abrir uma segunda tela, mantendo o jogo pausado. Como funciona? Quando você iniciar a aplicação, a tela exibida será semelhante à da versão Android, com um botão para entrar no game. Ao clicar no botão, a “segue” que criamos vai instanciar a View do Game, juntamente com o nosso View
232 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Controller (você mudou as classes da View e do ViewControler, como recomendado no passo 7?) Então, temos apenas que implementar a volta... Para começar, nosso método de captura de toque, “handleTapFrom”, tem que ser modificado: - (void)handleTapFrom:(UITapGestureRecognizer *) recognizer { if (simulando) { if (![self clicouEmTextura:recognizer]) { // Comanda a aplicação de forças b2Vec2 forca = b2Vec2(200.0f * bola->GetMass(), 200.0f * bola->GetMass()); b2Vec2 posicao = bola->GetWorldCenter(); posicao.y += 2; bola->SetAwake(true); bola->ApplyForce(forca, posicao); } } }
Fizemos praticamente a mesma coisa que na versão Android: criamos um método para verificar se o toque foi dentro de alguma textura “clicável”. Este método também é semelhante ao da versão Android: - (BOOL) clicouEmTextura: (UITapGestureRecognizer *) recognizer { BOOL resultado = NO; CGPoint ponto = [recognizer locationInView:recognizer. view]; OGBPCoordenada * toque = [[OGBPCoordenada alloc] initWithx:ponto.x OGBPCoordenada * toque = [[OGBPCoordenada alloc] initWithy:ponto.y OGBPCoordenada * toque = [[OGBPCoordenada alloc] initWith z:0.0f]; for (OGMTTextura * t in modeloTela.texturas) { if (t.clicavel) { if ([self toqueDentro:toque naTextura: t]) { resultado = true;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 233 [self dismissViewControllerAnimated:Y ES completion:nil]; } } } return resultado; } - (BOOL) toqueDentro: (OGBPCoordenada *) toque naTextura: (OGMTTextura *) t { BOOL resultado = false; OGMTPosicaoTela * p = t.posicaoAtual; float cTop = (alturaViewPort / 2) - ((p.topo * diagonalTela) / 100); float cLeft = - (larguraViewPort / 2) + ((p.esquerda * diagonalTela) / 100); float cAltura = (p.altura * diagonalTela) / 100; float cLargura = (p.largura * diagonalTela) / 100; float cBottom = cTop - cAltura; float cRight = cLeft + cLargura; float toqueX = toque.x - (larguraViewPort / 2); float toqueY = (alturaViewPort / 2) - toque.y; if ((toqueX >= cLeft && toqueX <= cRight) && (toqueY >= cBottom && toqueY <= cTop)) { resultado = YES; } }
return resultado;
Para dispensar a tela do Game, eu uso o método “dismissViewControllerAnimated”, da classe “GLKViewController”. Pronto! Temos uma tela de Game integrada a uma aplicação normal iOS.
Tempo e movimento Lembra-se que eu havia dito para não se preocupar com o Game loop, por que mais tarde eu entraria nesse assunto? Bem, o momento chegou! Vamos dar uma “ajeitada” no nosso Game loop.
234 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Um dos grandes problemas dos desenvolvedores de game é evitar “lags”. Como já discutimos, “lag” em game significa falha do game em acompanhar o ritmo do jogador. Este termo tem conotação diferente quando aplicado a jogos Online, especialmente MMORPGs (jogos multiplayer online), denotando o atraso de comunicação entre a estação do jogador e o servidor do game. O “lag” de comunicação também acontece em dispositivos online, quando jogamos MMOs, mas, neste caso, o conceito de “lag” ao qual estou me referindo é o descompasso entre o Game Loop, o Render Loop e o ritmo do jogador. Só que existe o contrário também: aceleração involuntária, que acontece se você executar o mesmo game em um dispositivo mais rápido. Para evitar “lags”, nós podemos otimizar os mecanismos de cálculo e renderização, utilizando OpenGL ES, o que geralmente dá bons resultados. Porém, para evitar aceleração involuntária isto não ajuda muito. A única maneira é sincronizar todos os movimentos do jogo de acordo com o tempo real, usando uma taxa de atualização compatível com a maioria dos dispositivos.
Game Loop Muito se fala sobre como o Game loop e sobre o Render loop. Basicamente, suas funções são: • Game loop: atualiza o Modelo do game (lembre-se do padrão Model-View-Controller), seja usando o “step” do Box2D, ou calculando novas posições de cada objeto móvel “na mão”. Ele também verifica se objetivos foram alcançados, alvos atingidos etc; • Render loop: atualiza a Visão (view) a partir do Modelo (Model). Ele deve gerar um novo “frame” visível para o jogador, com a posição corrente de todos os GameObjects, além das informações e indicadores (HUD); Existem duas abordagens para implementar os dois, e eu as tenho mostrado nos últimos exemplos. Game loop e Render loop sincronizados Isto significa que os dois são executados em sequência, dentro de uma taxa de atualização fixa. Antes de concluir uma iteração, o Game loop invoca o Render loop, seja diretamente ou indiretamente (invalidando a visão). O que importa é que o código dos dois é executado pelo mesmo Thread.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 235
As principais vantagens desta abordagem são: • A arquitetura e a implementação são mais fáceis; • Não necessita de sinalizadores para serializar o acesso (“synchronized”); • Consigo manter mais facilmente uma taxa consistente de atualização; Eu tenho feito isso nas versões iOS dos últimos exemplos. Note que eu crio a lógica de atualização dentro do método “delegate” “update”, do meu ViewController: #pragma mark - GLKView and GLKViewController delegate methods - (void)update { @synchronized(self) { world->Step(1.0f / cenaCorrente.fps, cenaCorrente.box2d.velocityInterations, cenaCorrente.box2d.positionInterations); } }
O GLKViewController já cria um loop de atualização, que invoca em sequência o método “update”, e comanda a atualização da GLKView, que invoca o método “drawInRect”. Note que a diretiva “@synchronized” está aí apenas para “efeitar”, pois não tem efeito prático porque não há threads concorrentes tentando acessar os mesmos dados. Eu a deixei aí apenas para efeito de marcar uma possível migração para abordagem assíncrona. É uma implementação simples e prática, que nos isola de muitos problemas. Podemos até configurar a taxa de atualização do GLKViewController através da propriedade “preferredFramesPerSecond”. O valor default é 30 FPS. O GLKViewController vai utilizar uma taxa de FPS próxima à que você deseja e você pode consultá-la através da propriedade “framesPerSecond”. A maioria dos desenvolvedores de game considera que esta abordagem (Game loop e Render loop sincronizados) é suficiente. Apenas em casos ultraextremos, nos quais a renderização ou a atualização podem passar por “picos” de demora, seria interessante separar os dois loops. Game loop e Render loop assíncronos Nesta abordagem, o Game loop é executado por um “Thread” separado do Render loop, com sua própria taxa de atualização. O Render loop é ativado
236 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
independentemente do Game loop haver completado sua tarefa e vai acessar os dados necessários para renderizar a tela, o que pode causar “Race condition”, logo, é necessário serializar o acesso ao Modelo do game. A principal vantagem dessa abordagem é tornar os dois loops independentes, logo, se um demorar mais do que o outro, teoricamente isto não afetará ambos. Porém, cria-se um problema de sincronização muito grande. Imaginemos que o Render loop demorou mais do que o previsto, e o Game loop atualizou muita coisa, atingindo um alvo, por exemplo. O Render loop vai demorar algum tempo até chegar nesse ponto, o que pode causar “lag”. Eu usei essa abordagem nos exemplos Android de propósito, apenas para mostrar que é possível e que, em jogos simples, não faz a menor diferença, afinal, o desempenho dos exemplos no Android e no iOS é quase o mesmo, independentemente da diferença arquitetural (CPU, GPU, memória etc). No Android, o Render loop é processado automaticamente pela classe derivada de “GLSurfaceView.Renderer”. Se nós deixarmos nos valores “default”, o “renderMode” será RENDERMODE_CONTINUOUSLY, o que significa que o método “onDrawFrame()”, do Renderer, será invocado continuamente. Qual é a consequência disso? Bem, a principal é o gasto de bateria, se o aparelho estiver rodando desconectado de uma fonte de alimentação. Além, é claro, da sobrecarga no processador. Neste caso, eu preferi criar um Thread separado para invocar o Game loop: public void runGameLoop() { simulando = true; task = new GameLoopTask(); timer = new Timer(); timer.scheduleAtFixedRate(task, 0, (long) ((1 / cenaCorrente.getFps()) * 1000)); } public void gameLoop() { synchronized (world) { // Um lembrete de que pode haver problemas de concorrência update(); }; } class GameLoopTask extends TimerTask { @Override
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 237 public void run() { gameLoop(); } }
Eu estou utilizando o recurso de “Timer” com “TimerTask” para executar o meu método “update()” dentro da taxa de FPS que foi configurada no modelo do game (“modeloGame.xml”). O método “scheduleAtFixedRate” espera alguns parâmetros, entre eles o período de repetição da atualização. Eu simplesmente converti o valor em FPS em milissegundos para cada repetição do método “update()”. Neste exemplo, eu tenho 30 FPS no modelo, logo, o Game loop será invocado a cada 33,333336 milissegundos (é a mesma coisa que dividir 1000 pela taxa de FPS, mas eu quis deixar claro o cálculo). Bem, como estou invocando o “update()” em um Thread separado do Render loop, tanto o código de atualização como o de renderização precisam serializar o acesso ao Modelo do game: protected void update() { /* * Atualiza o mundo Box2D e calcula a projeção */ synchronized(world) { world.step(1.0f / cenaCorrente.getFps(), cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations()); } } ... @Override public void onDrawFrame(GL10 gl) { ... // Vamos renderizar os Game Objects: GLES20.glEnable(GLES20.GL_DEPTH_TEST); GLES20.glDepthMask(true); synchronized(world) { for (GameObject go : cenaCorrente.getObjetos()) {
238 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS if (go.getArquivoTextura() != null) { ... float posicaoX = go.getB2dBody().getTransform().position.x * proporcaoMetroTela; float posicaoY = go.getB2dBody().getTransform().position.y * proporcaoMetroTela; Matrix.translateM(matrizModelo, 0, posicaoX, posicaoY, 0); Matrix.rotateM(matrizModelo, 0, (float) go.getB2dBody().getAngle() * 57.2957795), 0, 0, 1); ...
Os códigos suscetíveis a apresentar “race conditions” são os que estão em negrito. Note que eu protegi a atualização do mundo “Box2D” e o acesso às posições dos objetos. Se eu estiver criando novos GameObjects em meu Game loop, então tenho que proteger isso também nos dois pontos (Game loop e Render loop). Finalmente, eu estou comandando a atualização do meu mundo Box2D informando a taxa de FPS que está no modelo (ainda não estou satisfeito com o “timeStep” que estou usando): world.step(1.0f / cenaCorrente.getFps(), cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations());
Eu poderia ter deixado o “onDrawFrame()” controlar os dois, tomando cuidado para só atualizar e desenhar o frame dentro da taxa de FPS desejada. Por exemplo, algo assim: public void onDrawFrame(GL10 gl) { // Calcular difereça de tempo passada:
agora = System.currentTimeMillis();
intervaloPassado = agora – ultimo;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 239
{
// Colocar o Thread em “sleep mode”, pois falta // algum tempo para iniciar Thread.Sleep(33 – intervaloPassado);
}
if (intervaloPassado < cenaCorrente.getFPS())
ultimo = System.currentTimeMillis();
// Invocar a atualização:
update(); // Código de renderização: GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT COLOR_BUFFER_BIT); GLES20.glUseProgram(programaGLES); ... }
|
GLES20.GL_
Para games casuais, especialmente os que utilizam modelos 2D, um único Thread é mais do que suficiente. O mais importante é garantir que o modelo esteja sendo atualizado e renderizado a uma taxa consistente de FPS, que não precisa ser exata. Melhorando o Game loop Vamos ver uma melhoria em nosso GameLoop, primeiramente na versão Android. Vamos pegar o projeto anterior e dar uma melhorada. O fonte está em: • Android: “..\Codigo\OpenGLAndroid\gameloopdroid.zip”; • iOS: “\Codigo\OpenGLiOS\GameLoopIOS.zip”; Este exemplo roda o game bem próximo à taxa de FPS desejada e, ainda por cima, mostra na tela qual é o valor de FPS médio a cada segundo.
240 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 85: Novo Game loop com informação de FPS
Eu já testei até com 60 FPS no meu dispositivo menos potente: um smartphone Android LG p500, com 600 mhz de clock, e funcionou bem. Se aumentar muito o FPS vai notar que a bola gira em alta velocidade e se move lentamente. Como estamos aplicando a força em um vetor acima do centro, o torque gerado absorve grande parte da energia. Se chutar mais próximo ao centro, verá que ela se move mais rapidamente. Ajuste para telas de proporções diferentes de 2/3 Antes de mais nada, temos que fazer um ajuste no nosso framework... Quando criei o Modelo de Tela, eu sabia que existiam telas de proporções diferentes de 2/3. Por exemplo, 320 x 480 e 480 x 720 têm razão = 2/3. E o que isso importa? Bem, eu calculo TODAS as medidas a partir da proporção Metro/Tela, logo, se a proporção é mais “esticada”, isto pode fazer com que texturas posicionadas muito próximas aos limites (superior, inferior, esquerdo e direito) fiquem parcialmente fora da tela. Quando fui testar este exemplo em um tablet Motorola Xoom 2 Media Edition, cuja tela tem 800 x 1280 pixels (razão = 0,625), notei que algumas texturas estavam com parte fora da tela. Isto não acontece com os GameObjects, porque eu posiciono limites (teto, chão, parede direita e parede esquerda). Mas com as posições do Modelo de Tela, isto pode acontecer. Então, criei um “filtro” para telas com proporção menor que 2/3. Este filtro desloca ligeiramente o centro, de modo que todas as posições do modelo de tela fiquem dentro dos limites. Já testei em vários tipos de emulador e de dispositivos, com sucesso.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 241
No Android Crie um método de verificação na classe “OpenGLAvancadoRenderer”: protected void verificarNovaProporcao(Coordenada centro, float metadeAltura, float metadeLargura) { float esquerda = centro.getX() - metadeLargura; float direita = centro.getX() + metadeLargura; float topo = centro.getY() + metadeAltura; float baixo = centro.getY() - metadeAltura; float limiteEsquerdo = -1 * (larguraViewPort / 2); float limiteDireito = larguraViewPort / 2; float limiteSuperior = alturaViewPort / 2; float limiteInferior = -1 * (alturaViewPort / 2); if (esquerda < limiteEsquerdo) { centro.setX(centro.getX() + (limiteEsquerdo - esquerda)); } else if (direita > limiteDireito) { centro.setX(centro.getX() - (direita - limiteDireito)); } if (topo > limiteSuperior) { centro.setY(centro.getY() - (topo - limiteSuperior)); } else if (baixo < limiteInferior) { centro.setY(centro.getY() + (limiteInferior - baixo)); } }
Este método verifica se as bordas da posição ficarão fora dos limites da tela e, neste caso, desloca ligeiramente o centro da posição. Este método deve ser invocado antes de calcular os vértices finais das posições, o que ocorre no método “carregarModeloTela()”: private void carregarModeloTela() { mapaCaracteres = new HashMap(); for (PosicaoTela p : modeloTela.getPosicoes()) { float cTop = (alturaViewPort / 2) - ((p.getTopo() * diagonalTela) / 100);
242 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS float cLeft = - (larguraViewPort / 2) + ((p.getEsquerda() * diagonalTela) / 100); float cAltura = (p.getAltura() * diagonalTela) / 100; float cLargura = (p.getLargura() * diagonalTela) / 100; float metadeAltura = cAltura / 2.0f; float metadeLargura = cLargura / 2.0f; Coordenada centro = new Coordenada(cLeft + metadeLargura, cTop - metadeAltura, 0); if (((float) alturaViewPort / (float) larguraViewPort) < 0.66f ) { // A proporção é menor que 2/3, precisamos verificar os centros verificarNovaProporcao(centro, metadeAltura, metadeLargura); } p.setScreenTop(centro.getY() + metadeAltura); p.setScreenLeft(centro.getX() - metadeLargura); p.setScreenBottom(centro.getY() - metadeAltura); p.setScreenRight(centro.getX() + metadeLargura); p.sethVobVertices(carregarCoordenadasVertice(centro, cAltura, cLargura)); }
No iOS: - (void) carregarModeloTela { mapaCaracteres = [[NSMutableDictionary alloc] init]; for (OGMTPosicaoTela * p in modeloTela.posicoes) { float cTop = (alturaViewPort / 2) - ((p.topo * diagonalTela) / 100); float cLeft = - (larguraViewPort / 2) + ((p.esquerda * diagonalTela) / 100);
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 243 float float float float
cAltura = (p.altura * diagonalTela) / 100; cLargura = (p.largura * diagonalTela) / 100; metadeAltura = cAltura / 2.0f; metadeLargura = cLargura / 2.0f;
OGBPCoordenada * centro = [[OGBPCoordenada alloc] initWithx:(cLeft + metadeLargura) y:(cTop - metadeAltura) z:0]; if (((float) alturaViewPort / (float) larguraViewPort) < 0.66f ) { // A proporção é menor que 2/3, precisamos verificar os centros [self verificarNovaProporcao:centro metadeAltura:metadeAltura metadeLargura:metadeLargura]; } p.screenTop = centro.y + metadeAltura; p.screenLeft = centro.x - metadeLargura; p.screenBottom = centro.y - metadeAltura; p.screenRight = centro.x + metadeLargura; p.hVobVertices = [self carregarCoordenadasVertice:centro altura:cAltura largura:cLargura];
Eu criei quatro novas propriedades na classe de posição, de modo a representar os limites (possivelmente ajustados) da textura. Eu uso isso no teste de toque dentro de texturas: Android: private boolean toqueDentro(Textura t, Coordenada toque) { boolean resultado = false; PosicaoTela p = t.getPosicaoAtual(); float toqueX = toque.getX() - (larguraViewPort / 2); float toqueY = (alturaViewPort / 2) - toque.getY(); if ((toqueX >= p.getScreenLeft() && toqueX <= p.getScreenRight()) && (toqueY >= p.getScreenBottom() && toqueY <= p.getScreenTop())) { resultado = true;
244 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
}
}
return resultado;
iOS: - (BOOL) toqueDentro: (OGBPCoordenada *) toque naTextura: (OGMTTextura *) t { BOOL resultado = false; OGMTPosicaoTela * p = t.posicaoAtual; float toqueX = toque.x - (larguraViewPort / 2); float toqueY = (alturaViewPort / 2) - toque.y; if ((toqueX >= p.screenLeft && toqueX <= p.screenRight) && toqueY >= p.screenBottom && toqueY <= p.screenTop)) { resultado = YES; } }
return resultado;
Isto garante que as texturas do modelo sempre ficarão dentro da tela, independentemente da proporção entre altura e largura. Voltando ao Game Loop... Para começar, eu acabei com o Thread extra. Agora, o Game loop é invocado dentro do Render loop, ou seja, no início do método “onDrawFrame()”. E não é só isso: eu também sincronizei o Game loop para que apresente uma taxa de FPS próxima à que informamos no XML. Veja as mudanças no método “onDrawFrame()”: @Override public void onDrawFrame(GL10 gl) { // Controle de FPS: long agora = System.currentTimeMillis(); long intervaloPassado = tempo; if (ultimo > 0) { intervaloPassado = agora - ultimo;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 245 } if (intervaloPassado < tempo) { // Colocar o Thread em “sleep mode”, pois falta // algum tempo para iniciar try { Thread.sleep((long) (tempo - intervaloPassado)); intervaloPassado += (tempo - intervaloPassado); } catch (InterruptedException e) { Log.e(“GAMELOOP”, “Interrompido o sleep: “ + e.getMessage()); } } // Verifica se atingiu 1 segundo contagemFrames++; segundo += intervaloPassado; if (segundo >= 1000) { // atingiu 1 segundo FPSmedio = contagemFrames; segundo = 0; contagemFrames = 1; } ultimo = System.currentTimeMillis(); update(intervaloPassado / 1000.0f);
Para começar, eu preciso saber quantos milissegundos se passaram, desde a última vez que o “onDrawFrame()” foi invocado. Se foi um intervalo menor que o número de milissegundos relativo à taxa de FPS que eu estou usando, eu calculo a diferença e boto o Thread para “dormir”. O campo “tempo” é calculado assim: ((1 / cenaCorrente.getFps()) * 1000). Depois, eu verifico se já passou 1 segundo completo, então eu exibo a quantidade de frames renderizada naquele segundo. Para terminar, o método “update()” agora é invocado pelo Render loop e eu passo o intervalo de tempo, desde a última atualização, como parâmetro. Este intervalo é acrescido do tempo em que o Thread ficou “dormindo”. No método “update()”, uso o diferencial de tempo (deltaT) para atualizar o “mundo” Box2D (note que não tem mais o “synchronized”): protected void update(float deltaT) { /*
246 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS }
* Atualiza o mundo Box2D e calcula a projeção */ world.step(deltaT, cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations());
O resultado é um Game loop simples, porém com uma taxa de FPS consistente, que me permite desenvolver o game sem “lags” e acelerações indevidas. É claro que a exibição do FPS acaba roubando algum tempo útil do Game, mas, em seu lugar nós certamente exibiríamos alguma outra informação no HUD, logo, a alteração é esperada. Vamos ver a implementação no iOS No iOS, eu já tenho o tempo decorrido como uma propriedade da classe “GLKViewController”, então, eu movi o código de verificação de FPS para dentro do método “update”: - (void)update { float deltaT = [self timeSinceLastUpdate]; // Controle de FPS:
if (deltaT < tempo) { // Colocar o Thread em “sleep mode”, pois falta // algum tempo para iniciar [NSThread sleepForTimeInterval:(tempo - deltaT)]; //NSLog(@” deltaT %f sleep: %f”, deltaT, (tempo - deltaT)); }
// Verifica se atingiu 1 segundo contagemFrames++; segundo += deltaT; if (segundo >= 1) { // atingiu 1 segundo FPSmedio = contagemFrames; segundo = 0; contagemFrames = 1; }
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 247 world->Step(deltaT, cenaCorrente.box2d.velocityInterations, cenaCorrente.box2d.positionInterations); }
Então o código ficou mais simples.
Movimento Já vimos algumas opções para criar um Game loop, agora é necessário ver como movimentar os GameObjects de forma consistente, evitando a “aceleração indevida”. Se estamos utilizando Box2D, basta passarmos a taxa de FPS no momento de invocar o método “step”: world.step(deltaT,
cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations());
E se estivermos movimentando um objeto manualmente? Um míssil, por exemplo? Como calcular a o deslocamento de forma consistente? Para começar, temos que considerar a velocidade. A melhor medida é metros por segundo (m/s). Por exemplo, 60 km por hora representam 16,66667 m/s. Como já temos a conversão de 1 metro em pixels (usamos o campo “proporcaoMetroTela” para calcular isso), podemos estabelecer um padrão de velocidade em m/s. Tomando por base uma tela de 320 x 480 pixels de largura, com proporcaoMetroTela de 7, quantos segundos um objeto se movendo a 60 km/h demoraria para atravessá-la? • Diagonal é aproximadamente 577 pixels; • 1 m = 577 / 7 = 82 pixels, aproximadamente; • 480 pixels = 6 metros, aproximadamente; • 60 km/h = 16,7 m/s, aproximadamente; • Logo, o tempo seria menos de meio segundo, ou seja: rápido demais. Temos que ajustar o tempo de acordo com o que desejamos. Se nossa tela tem 6 metros e queremos que o “míssil” demore 3 segundos, então a velocidade deveria ser entre 2 e 3 m/s ou aproximadamente 8 km/h. Se você quiser, pode aumentar o valor da proporcaoMetroTela. Lembre-se que, apesar de calcular o tempo de percurso com precisão, na realidade a velocidade do objeto pode variar devido a outros fatores, sobre os
248 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
quais não temos muito controle. Logo, é uma estimativa de velocidade e de tempo. Ok, então já sabemos como calcular a velocidade que nosso objeto precisa desenvolver, agora, como fazer com que o Game loop respeite isso de forma consistente? Uma maneira é “represar” a atualização até que o tempo seja atingido. Vamos ver isso em pseudocódigo: update(ultimaAtualizacao) { tempo += (agora – ultimaAtualizacao); se (tempo >= 1 segundo) { atualizarObjeto(); tempo = 0; } }
Este comportamento parece bom, não? Só que daria um efeito parecido com ponteiro de segundos, ou seja, o objeto aparenta se mover e parar constantemente. Uma maneira melhor seria: update(ultimaAtualizacao) { tempo = (1 segundo ) / (agora – ultimaAtualizacao); atualizarObjetos(tempo); }
Agora, estamos movendo constantemente o objeto, mesmo que uma pequena fração de cada vez, dando a ilusão de movimento contínuo e suave. Nada como um exemplo Vamos pensar em um outro exemplo: uma nave que atravessa a tela na horizontal, da esquerda para a direita, em velocidade constante relativa de 8 m/s. O código-fonte deste exemplo está em: • Android: “..\Codigo\OpenGLAndroid\movimentodroid.zip”; • iOS: “..\Codigo\OpenGLiOS\MovimentoIOS.zip”; As imagens que estou usando são de domínio público e vieram todas do site “openclippart.org”.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 249
Ilustração 86: Exemplo de movimento controlado sem Box2D
Ilustração 87: O mesmo exemplo no iOS
Neste exemplo, você verá uma nave atravessar a tela, da esquerda para a direita, repetidamente. A velocidade da nave é 3 m/s, ela vai percorrer a distância em cerca de 3 segundos, dependendo da proporção entre altura e largura da tela. Se fração altura/largura for proporcional a 2/3, então ela demorará cerca de 3 segundos para atravessar a tela. Eu poderia ter criado um novo “base code”, tirando o Box2D e simplificando tudo, mas achei melhor reusar o framework de game que temos, afinal, eu posso misturar objetos animados com Box2D e objetos animados manualmente, o que é comum em games. Então, usei o mesmo “base code” do exemplo anterior (“GameLoop”) e fiz algumas mudanças. Todas estão precedidas pelo comentário “// @@@@ Alteração” assim, você saberá onde eu modifiquei o código-fonte.
250 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Para começar, eu criei uma nova propriedade nos meus GameObjects, que está dentro do arquivo “modeloExemplo.xml”: 1 2 1 0 0 2 0.5 false 1.5 false nave.png false 1.0 0.1 0.6
Eu tenho que alterar as classes que carregam o modelo, de modo a incluir esta propriedade. A propriedade “fisicaBox2d” afetará os seguintes pontos: 1. Deslocamento do centro, caso haja opção de alinhamento. Neste caso, estou alinhando a nave à esquerda da tela; 2. Cálculo do VOB de vértices; 3. Cálculo do movimento; 4. Renderização do GameObject; O importante é que não terei um objeto Box2D para me informar a posição e o ângulo do objeto, logo, terei que pegar as coordenadas do Centro do GameObject, que já estão convertidas de acordo com a matriz de projeção, logo, não é necessário multiplicar pela proporção Metro/Tela, então, em vários pontos do código-fonte eu tive que testar se o GO era animado pelo Box2D ou não. Android: Classe “OpenGLAvancadoRenderer”: Várias alterações dentro do método “onSurfaceChanged()”: ...
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 251 case ALINHAMENTO_ESQUERDA: go.setCentro( new Coordenada(this.telaTopLeft.getX(), go.getCentro().getY(),0.0f) ); // @@@@ Alteração if (go.isFisicaBox2D()) { go.getB2dBody().setTransform(new Vec2(this. telaTopLeft.getX() / proporcaoMetroTela, go.getCentro().getY()), 0.0f); } break; ... // @@@@ Alteração if (go.isFisicaBox2D()) { // Recalcula os tamanhos dos objetos if (go.getFixture() != null) { body.destroyFixture(go.getFixture()); } ... if (go.getArquivoTextura() != null) { Coordenada centro = null; // @@@@ Alteração if (go.isFisicaBox2D()) { centro = new Coordenada(go.getCentro().getX() * proporcaoMetroTela, go.getCentro().getY() * proporcaoMetroTela, go.getCentro().getZ()*proporcaoMetroTela); } else { centro = go.getCentro(); } ...
Dentro do método “initBox2d()”: ...
252 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS for (GameObject go : cenaCorrente.getObjetos()) { // @@@@ Alteração para permitir objetos não animados pelo Box2D: if (go.isFisicaBox2D()) { ...
Dentro do método “onDrawFrame()”: ... float posicaoX = 0.0f; float posicaoY = 0.0f; float angulo = 0.0f; // @@@@ Alteração para permitir objetos não animados pelo Box2D: if (go.isFisicaBox2D()) { posicaoX = go.getB2dBody().getTransform().position.x * proporcaoMetroTela; posicaoY = go.getB2dBody().getTransform().position.y * proporcaoMetroTela; angulo = go.getB2dBody().getAngle(); } else { posicaoX = go.getCentro().getX(); posicaoY = go.getCentro().getY(); } Matrix.translateM(matrizModelo, 0, posicaoX, posicaoY, 0); Matrix.rotateM(matrizModelo, 0, (float) (angulo * 57.2957795), 0, 0, 1); ...
Finalmente, dentro do método “update()”: protected void update(float deltaT) { /* * Atualiza o mundo Box2D e calcula a projeção */
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 253 world.step(deltaT, cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations()); GameObject nave = cenaCorrente.getObjetos().get(0); nave.getCentro().setX(nave.getCentro().getX() + (velocidadeMS * deltaT)); if (nave.getCentro().getX() > larguraViewPort) { nave.getCentro().setX(origemX); } }
Esta alteração simplesmente calcula a nova posição da nave, que não está sob controle do Box2D. Eu multiplico a velocidade em m/s pelo diferencial de tempo de atualização. A velocidade é calculada no método “carregarCena()”: ... diagonalTela = (float) (Math.pow(alturaViewPort, 2) + Math. pow(larguraViewPort, 2)); diagonalTela = (float) Math.sqrt(diagonalTela); proporcaoMetroTela = (float) (diagonalTela / cenaCorrente. getBox2d().getProporcaoMetroTela()); // @@@@ Alteração: velocidadeMS = 3.0f * proporcaoMetroTela; ...
No iOS: Várias alterações dentro do método “recalcularAlinhamentos”: ... case ALINHAMENTO_ESQUERDA: { go.centro = [[OGBPCoordenada alloc] initWithx: telaTopLeft.x y: go.centro.y z: 0.0f]; // @@@@ Alteração: if (go.fisicaBox2D) { novaCoord.x = telaTopLeft.x / proporcaoMetroTela; novaCoord.y = go.centro.y;
254 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS go.b2dBody->SetTransform(novaCoord, go.b2dBody-> GetAngle()); } break; ... // @@@@ Alteração if (go.fisicaBox2D) { if (go.fixture != nil) { body->DestroyFixture(go.fixture); } ... if (go.arquivoTextura != nil) { // @@@@ Alteração OGBPCoordenada * coordCentro = nil; if (go.fisicaBox2D) { OGBPCoordenada * coordCentro = [[OGBPCoordenada alloc] initWithx: (go.centro.x * proporcaoMetroTela) y:(go.centro.y * proporcaoMetroTela) z:(go.centro.z * proporcaoMetroTela)];
} ...
} else { coordCentro = go.centro;
Dentro do método “initBox2d()”: ... for (OGBPGameObject * go in cenaCorrente.objetos) { // @@@@ Alteração if (go.fisicaBox2D) { ...
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 255
Dentro do método “onDrawFrame()”: ... // @@@@ Alteração float posicaoX = 0.0f; float posicaoY = 0.0f; float angulo = 0.0f; if (go.fisicaBox2D) { posicaoX = go.b2dBody->GetPosition().x * proporcaoMetroTela; posicaoY = go.b2dBody->GetPosition().y * proporcaoMetroTela; angulo = go.b2dBody->GetAngle(); } else { posicaoX = go.centro.x; posicaoY = go.centro.y; } matrizModelo = GLKMatrix4Translate(matrizModelo, posicaoX, posicaoY, 0.0f); matrizModelo = GLKMatrix4Rotate(matrizModelo, angulo, 0, 0, 1); ...
Finalmente, dentro do método “update()”: protected void update(float deltaT) { /* * Atualiza o mundo Box2D e calcula a projeção */ world->Step(deltaT, cenaCorrente.box2d.velocityInterations, cenaCorrente.box2d.positionInterations); // @@@@ Alteração OGBPGameObject * nave = [cenaCorrente.objetos objectAtIndex:0];
256 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS nave.centro.x= (nave.centro.x + (velocidadeMS * deltaT)); if (nave.centro.x > larguraViewPort) { nave.centro.x = origemX; } }
Esta alteração simplesmente calcula a nova posição da nave, que não está sob controle do Box2D. Eu multiplico a velocidade em m/s pelo diferencial de tempo de atualização. A velocidade é calculada no método “carregarCena()”: ...
diagonalTela = pow(alturaViewPort, 2) pow(larguraViewPort, 2); diagonalTela = sqrt(diagonalTela); proporcaoMetroTela = (float) (diagonalTela / cenaCorrente.box2d.proporcaoMetroTela);
+
// @@@@ Alteração: ...
velocidadeMS = 3.0f * proporcaoMetroTela;
Estou utilizando 3 m/s, e testei em vários dispositivos, tanto Android como iOS. O tempo de percurso da tela fica em torno de 3 segundos, com pequena variação, nada que atrapalhe o “gameplay”. Assim, continuo podendo criar jogos multiplataforma, que apresentem a mesma jogabilidade, independentemente do tamanho (e proporção) da tela, ou da velocidade do processador.
Efeito de paralaxe Paralaxe é um conceito de astronomia, mas, em games, é uma técnica para aumentar a ilusão de profundidade em jogos 2D. Também é conhecido como “Parallax scrolling”. Nesta técnica, os objetos que estão em planos mais afastados (com relação ao observador) se deslocam em velocidade menor que os objetos que estão em planos mais próximos (do observador). É claro que você pode conseguir isso facilmente em um jogo 3D, afinal, a própria projeção dos elementos vai lhe proporcionar o efeito de paralaxe, caso você decida “seguir” um jogador com a “câmera”. Mas em jogos 2D, é um pouco mais complicado.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 257
Técnicas de paralaxe Existem algumas técnicas para conseguir o efeito de paralaxe: Camadas Todos os sistemas gráficos possuem o conceito de camadas. Uma camada é um contexto gráfico, no qual podemos desenhar, e elas podem ser sobrepostas, formando um “sanduíche” que é a tela final. Nós podemos deslocar ligeiramente as camadas com diferentes unidades, criando o efeito de paralaxe. É uma técnica interessante, só que exige maior conhecimento e investimento no sistema gráfico nativo de cada plataforma.
Ilustração 88: Paralaxe com camadas
Camadas de GameObjects Outra maneira mais simples é criar camadas lógicas de GameObjects, separando-os em grupos: primeiro plano, segundo plano e terceiro plano. Normalmente, os objetos em primeiro plano são aqueles com os quais o jogador pode interagir e também respondem a eventos, colisões etc. Os de outros planos servem apenas como “cenário”. Nesta técnica, só existe uma única camada física de desenho, mas os objetos são animados ANTES da renderização, com velocidades diferentes. Temos uma velocidade para o primeiro plano, uma menor para o segundo plano e uma ainda menor para o terceiro plano. Na verdade, podemos ter quantos planos desejarmos. Esta técnica é independente de plataforma nativa e pode ser facilmente implementada.
258 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Exemplo de paralaxe com camadas de GameObjects Como a intenção deste livro é ser multiplataforma, escolhi a técnica de camadas lógicas de GameObjects e criei um exemplo simples.
Ilustração 89: O exemplo em plataforma Android
Ilustração 90: O exemplo em plataforma iOS
O carro se move para o sentido direito da tela, mas, na verdade, está parado. Só criei uma ilusão para que as rodas pareçam se mover (duas imagens sendo alternadas a cada 2 frames). As árvores e postes estão em segundo plano e se alternam, aleatoriamente, e as casas, prédios e o morro estão em terceiro plano, também se alternando aleatoriamente. O código-fonte dos exemplos está em: • Android: “..\Codigo\OpenGLAndroid\paralaxdroid.zip”; • iOS: “..\Codigo\OpenGLiOS\ParalaxIOS.zip”;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 259
Implementação geral Eu peguei o exemplo “Movimento” e fiz as mudanças. Para começar, eu criei GameObjects invisíveis e animados por fora do Box2D: 1 2 1 0 0 0 5 1.5 false 3.0 false carro.png false false 1.0 0.1 0.6
Os objetos agora têm a coordenada “z” em seu centro. Aqueles que estão com z = 0, estão no primeiro plano, os que estão com z = -1, estão em segundo plano e, finalmente, aqueles que estão com z = -2, estão em terceiro plano. Os objetos com a propriedade “visivel” = false, não serão renderizados pelo Render loop. Eu terei que ativar os objetos a serem renderizados manualmente. Eu aproveitei a velocidadeMS, criada no exemplo “Movimento” e calculei em 2 m/s. Como eu já calculo quanto vale 1 metro, eu calculei a velocidade básica em 2 vezes o tamanho de 1 metro em pixels, por segundo. No método de atualização (“update”), eu animo separadamente os objetos, de acordo com a camada. Os que estão no terceiro plano, desloco para a esquerda com metade da velocidadeMS. Os que estão em segundo plano,
260 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
eu desloco com a própria velocidadeMS. E os que estão em primeiro plano, apenas substituo a imagem do carro a cada 2 frames. No método de renderização, eu desenho separadamente os objetos por camada. Na verdade, não precisava disto, pois bastava usar o filtro de profundidade do OpenGL (“glEnable(GL_DEPTH_TEST)” e “glDepthMask(GL_ TRUE)”), só que isto causa um problema na plataforma Android, pois as camadas de transparência das texturas (alfas) tentem se sobrepor. Por exemplo, criam “pontas” como se as texturas não fossem transparentes. Então, como o filtro de profundidade está desligado, eu tenho que renderizar os objetos camada por camada, começando com a mais afastada. Mas é importante calcular os vértices considerando z = 0. Caso contrário, cria um efeito indesejado ao aplicarmos a câmera. Separação de GameObjects por camada Agora, eu não tenho mais todos os GOs dentro da coleção de objetos da cenaCorrente. Eu tenho uma coleção para cada plano. Então, tenho que animar os objetos de acordo com seu plano. Quando um objeto sai de cena (seu limite direito é menor que o limite esquerdo da tela), eu crio um novo GO, escolhendo, aleatoriamente, na coleção de GOs do plano correspondente. Eu adiciono o novo GO a uma coleção específica (“adicionados”), da qual eu posso remover posteriormente. Para adicionar um novo GO, eu preciso “clonar” o objeto. Se eu apenas usar a referência, mudarei as propriedades do objeto original, e isso eu não quero fazer. Então, tive que empregar técnicas de clonagem de objetos, tanto em Android (“Cloneable”) como em iOS (“NSMutableCopying”). Quando eu vou desenhar os objetos, eu pego os de segundo e terceiro planos da coleção “adicionados”, que é mutável. Criar uma “pista” e alinhar a base dos objetos a ela Alinhar e posicionar objetos com o OpenGL é meio complicado. Eu criei um novo tipo de alinhamento para os GameObjects: 5 – ALINHAMENTO_ BASE_CHAO. Se um GO tem esse tipo de alinhamento, depois de calcular seus vértices, eu movimento seu centro para que a base fique sobre uma linha que fica 50% abaixo da linha de centro da tela. Aliás, eu alinho o objeto “pista” com o topo colado nessa linha. Assim, a pista fica alinhada com o topo em 50% da metade inferior da tela, e os objetos com ALINHAMENTO_BASE_CHAO ficam com a base sobre a pista.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 261
Se você quiser usar a parte inferior da tela (alinhar no limite físico de baixo), vai ter muito trabalho. Veja o próximo exemplo.
Implementação Android Vamos começar com as alterações na classe GameObject: public class GameObject implements Comparable , Cloneable { ... public static enum ALINHAMENTO_GO { ALINHAMENTO_NENHUM, ALINHAMENTO_CHAO, ALINHAMENTO_ESQUERDA, ALINHAMENTO_DIREITA, ALINHAMENTO_TETO, ALINHAMENTO_BASE_CHAO }; ... private boolean visivel; ... @Override public Object clone() throws CloneNotSupportedException { GameObject copia = new GameObject(); copia.setAlinhamento(this.getAlinhamento()); copia.setAltura(this.getAltura()); copia.setArquivoTextura(this.getArquivoTextura()); copia.setAtrito(this.getAtrito()); copia.setB2dBody(this.getB2dBody()); copia.setCentro(this.getCentro()); copia.setCoefRetribuicao(this.getCoefRetribuicao()); copia.setDensidade(this.getDensidade()); copia.setEsticarAltura(this.isEsticarAltura()); copia.setEsticarLargura(this.isEsticarLargura()); copia.setFisicaBox2D(this.isFisicaBox2D()); copia.setFixture(this.getFixture()); copia.setForma(this.getForma()); copia.setHandlerTextura(this.getHandlerTextura()); copia.setId(this.getId()); copia.setJaCalculado(this.isJaCalculado());
262 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS }
copia.setLargura(this.getLargura()); copia.setTipo(this.getTipo()); copia.setVisivel(this.isVisivel()); copia.setVobTextura(this.getVobTextura()); copia.setVobVertices(this.getVobVertices()); return copia;
A principal é a implementação da interface “Cloneable”, que exige a criação de um método para “clonar” um GameObject. Agora, vamos ver as modificações no método “onSurfaceChanged”: // Agora, vamos separar os objetos de segundo e terceiro planos segundoPlano = new ArrayList(); terceiroPlano = new ArrayList(); for (Iterator iterator = cenaCorrente.getObjetos(). iterator(); iterator.hasNext();) { GameObject go = iterator.next(); float alturaGo = (go.isJaCalculado()) ? go.getAltura() : go.getAltura() * proporcaoMetroTela; float larguraGo = (go.isJaCalculado()) ? go.getLargura() : go.getLargura() * proporcaoMetroTela; float linhaBase = this.telaBottomRight.getY() * 0.50f; if (go.getAlinhamento() == ALINHAMENTO_GO.ALINHAMENTO_ BASE_CHAO) { // Alinha todos na base go.getCentro().setY(linhaBase + alturaGo/2); } if (go.getId() == 11) { go.getCentro().setY(linhaBase - alturaGo/2); }
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 263 if (go.getCentro().getZ() == SEGUNDO_PLANO) { segundoPlano.add(go); } else if (go.getCentro().getZ() == TERCEIRO_PLANO) { terceiroPlano.add(go); } if (go.getId() == 1) { try { carro1 = (GameObject) go.clone(); carro1.setVisivel(true); carroAdesenhar = cenaCorrente.getObjetos(). indexOf(go); } catch (CloneNotSupportedException e) { e.printStackTrace(); } } else if (go.getId() == 2) { try { carro2 = (GameObject) go.clone(); carro2.setVisivel(true); } catch (CloneNotSupportedException e) { e.printStackTrace(); } } } // Por segurança, não dá para remover um objeto de uma coleção durante a iteração for (GameObject go : segundoPlano) { cenaCorrente.getObjetos().remove(go); } for (GameObject go : terceiroPlano) { cenaCorrente.getObjetos().remove(go); }
264 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS // Adiciona alguns objetos ao terceiro e segundo planos: addNewObject(SEGUNDO_PLANO); addNewObject(TERCEIRO_PLANO); }
Infelizmente, nem o Java e nem o Objective C resolveram um problema simples: como modificar os membros de uma coleção, durante a iteração. É um problema simples: estamos navegando em uma coleção e queremos remover um dos objetos. Isso não deveria ser problema, mas é. Se você quiser adicionar ou remover membros de uma coleção, faça em outro loop. Você pode usar coleções auxiliares para isto, é o que faço em certos momentos. Eu só posso fazer as modificações na posição dos objetos após ter carregado os vetores de vértices, ou seja, no final do método “onSurfaceChanged”. Note que eu clonei as imagens dos carros. E eu tive que fazer uma coisa que não gosto: usar número “mágico”. Eu preciso alinhar a pista com o topo na linha base, logo, eu procurei o objeto com id = 11. Na verdade, isto afeta o reuso deste código. Em um framework de verdade, eu faria essas modificações “hardcode” em uma linguagem de script, associada a um evento, como Lua, por exemplo. Finalmente, eu adicionei um objeto em cada plano. Vamos ver as modificações no método “update()”: protected void update(float deltaT) { /* * Atualiza o mundo Box2D e calcula a projeção */ world.step(deltaT, cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations()); // Anima objeto de primeiro plano numeroFrames++; if (numeroFrames > 2) { // Troca imagem do carro numeroFrames = 0; this.carroFinal = (this.trocou) ? carro2 : carro1; this.trocou = !this.trocou; cenaCorrente.getObjetos().set(this. carroAdesenhar,carroFinal); }
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 265 // Loop dos objetos de segundo e terceiro plano: int adicionarSegundoPlano = 0; int adicionarTerceiroPlano = 0; for (GameObject go : adicionados) { float antesX = go.getCentro().getX(); if (go.getCentro().getZ() == TERCEIRO_PLANO) { // Anima objeto de terceiro plano go.getCentro().setX(go.getCentro().getX() ((velocidadeMS * 0.5f) * deltaT)); } else if (go.getCentro().getZ() == SEGUNDO_PLANO) { // Anima objeto de segundo plano go.getCentro().setX(go.getCentro().getX() ((velocidadeMS) * deltaT)); } float direita = go.getCentro().getX() + ((go.getLargura() * proporcaoMetroTela) / 2); if (direita < this.telaTopLeft.getX()) { if (go.getCentro().getZ() == TERCEIRO_PLANO) { adicionarTerceiroPlano++; } else { adicionarSegundoPlano++; } } } List aRemover = new ArrayList(); for (GameObject go : adicionados) { float direita = go.getCentro().getX() + ((go.getLargura() * proporcaoMetroTela) / 2); if (direita < this.telaTopLeft.getX()) { aRemover.add(go); }
}
266 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS for (GameObject go : aRemover) { adicionados.remove(go); } for (int x=0; x < adicionarSegundoPlano; x++) { addNewObject(SEGUNDO_PLANO); } for (int x=0; x < adicionarTerceiroPlano; x++) { addNewObject(TERCEIRO_PLANO); } } protected void addNewObject(float plano) { List lista = plano == TERCEIRO_PLANO ? terceiroPlano : segundoPlano; int posicao = random.nextInt(lista.size()); GameObject go = null; try { go = (GameObject) lista.get(posicao).clone(); } catch (CloneNotSupportedException e) { Log.e(“CLONE”, “CloneNotSupported GameObject”); } go.setId(plano == TERCEIRO_PLANO ? 300 : 200); go.getCentro().setX(this.telaBottomRight.getX() + ((go.getLargura() * proporcaoMetroTela) / 2)); go.setVisivel(true); adicionados.add(go); }
Quando um objeto sai da tela visível, eu adiciono um novo, escolhendo, aleatoriamente, dentre os objetos daquele nível disponíveis (segundoPlano ou terceiroPlano). Infelizmente, eu só posso modificar os membros de uma coleção fora de sua iteração, então eu uso alguns artifícios para conseguir isso. Esta é a razão de eu ter vários loops e coleções separadas. Agora, vamos ver como ficou a renderização dos objetos, separados de acordo com o plano, o que acontece no método “onDrawFrame()”:
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 267 desenharTextos(); // Vamos renderizar os Game Objects de terceiro plano: for (GameObject go : adicionados) { if (go.getCentro().getZ() == TERCEIRO_PLANO) { desenharObjeto(go); } } // Agora, os de segundo plano: for (GameObject go : adicionados) { if (go.getCentro().getZ() == SEGUNDO_PLANO) { desenharObjeto(go); } } // Finalmente, os de primeiro plano: for (GameObject go : cenaCorrente.getObjetos()) { if (go.getArquivoTextura() != null && go.isVisivel()) { desenharObjeto(go); } }
Eu separei o código de renderização de GameObjects e o invoco em cada loop.
Implementação iOS A implementação iOS foi baseada no mesmo projeto, “MovimentoIOS”. As modificações na interface da classe “OGBPGameObject” foram: ... typedef enum { ALINHAMENTO_NENHUM, ALINHAMENTO_CHAO, ALINHAMENTO_ESQUERDA, ALINHAMENTO_DIREITA, ALINHAMENTO_TETO,
268 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS ALINHAMENTO_BASE_CHAO } ALINHAMENTO_GO; @interface OGBPGameObject : NSObject ... @property BOOL visivel; ...
E as modificações na implementação foram:
... @synthesize visivel; ... - (id) mutableCopyWithZone:(NSZone *)zone { OGBPGameObject *copiaGo = [[OGBPGameObject allocWithZone:zone] init]; copiaGo.alinhamento = self.alinhamento; copiaGo.altura = self.altura; copiaGo.arquivoTextura = [self.arquivoTextura copy]; copiaGo.atrito = self.atrito; copiaGo.b2dBody = self.b2dBody; copiaGo.centro = [self.centro mutableCopy]; copiaGo.coefRetribuicao = self.coefRetribuicao; copiaGo.densidade = self.densidade; copiaGo.esticarAltura = self.esticarAltura; copiaGo.esticarLargura = self.esticarLargura; copiaGo.fixture = self.fixture; copiaGo.forma = self.forma; copiaGo.idGO = self.idGO; copiaGo.largura = self.largura; copiaGo.tipo = self.tipo; copiaGo.glProps = [self.glProps mutableCopy]; copiaGo.fisicaBox2D = self.fisicaBox2D; return copiaGo; }
O método “mutableCopyWithZone” é o equivalente ao “clone()” do Java. As propriedades que são objetos Cocoa Touch, eu tenho que usar “copy” ou “mutableCopy”, dependendo se eu quero alterar o clone ou não. Como eu
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 269
tenho duas propriedades que são classes que eu criei, eu tenho que implementar o protocolo “NSMutableCopying” nelas também (OGBPCoordenada e OGBPGLProps). Agora, vamos ver as modificações no método “recalcularAlinhamentos”, que é o equivalente ao “onSurfaceChanged()”, do Android: segundoPlano = [[NSMutableArray alloc] init]; terceiroPlano = [[NSMutableArray alloc] init]; for (OGBPGameObject * go in cenaCorrente.objetos) { float alturaGo = go.altura * proporcaoMetroTela; float larguraGo = go.largura * proporcaoMetroTela; float linhaBase = telaBottomRight.y * 0.50f; if (go.alinhamento == ALINHAMENTO_BASE_CHAO) { // Alinha todos na base go.centro.y = linhaBase + alturaGo/2; } if (go.idGO == 11) { go.centro.y = linhaBase - alturaGo/2; } if (go.centro.z == SEGUNDO_PLANO) { [segundoPlano addObject:go]; } else if (go.centro.z == TERCEIRO_PLANO) { [terceiroPlano addObject:go]; } if (go.idGO == 1) { carro1 = [go mutableCopy]; carro1.visivel = YES; carroAdesenhar = [cenaCorrente.objetos indexOfObject:go]; } else if (go.idGO == 2) { carro2 = [go mutableCopy]; carro2.visivel = YES; } }
270 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS // Por segurança, não dá para remover um objeto de uma coleção durante a iteração for (OGBPGameObject * go in segundoPlano) { [cenaCorrente.objetos removeObject:go]; } for (OGBPGameObject * go in terceiroPlano) { [cenaCorrente.objetos removeObject:go]; } // Adiciona alguns objetos ao terceiro e segundo planos: [self addNewObject: SEGUNDO_PLANO]; [self addNewObject: TERCEIRO_PLANO];
O código é muito semelhante ao do método “onSurfaceChanged()”, da versão Android. A maneira de fazer as coisas é um pouco diferente, como a clonagem dos objetos, por exemplo. As modificações no método “update” foram: - (void)update { float deltaT = [self timeSinceLastUpdate]; // Controle de FPS: if (deltaT < tempo) { // Colocar o Thread em “sleep mode”, pois falta // algum tempo para iniciar [NSThread sleepForTimeInterval:(tempo - deltaT)]; //NSLog(@” deltaT %f sleep: %f”, deltaT, (tempo - deltaT)); } world->Step(deltaT, cenaCorrente.box2d.velocityInterations, cenaCorrente.box2d.positionInterations); // Anima objeto de primeiro plano contagemFrames++;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 271 if (contagemFrames > 2) { // troca imagem do carro contagemFrames = 0; carroFinal = (self->trocou) ? carro2 : carro1; self->trocou = !self->trocou; [cenaCorrente.objetos setObject:carroFinal at IndexedSubscript:carroAdesenhar]; } // Loop dos objetos de segundo e terceiro plano: int adicionarSegundoPlano = 0; int adicionarTerceiroPlano = 0; for (OGBPGameObject * go in adicionados) { float antesX = go.centro.x; if (go.centro.z == TERCEIRO_PLANO) { // Anima objeto de terceiro plano go.centro.x = go.centro.x - ((velocidadeMS * 0.5f) * deltaT); } else if (go.centro.z == SEGUNDO_PLANO) { // Anima objeto de segundo plano go.centro.x = go.centro.x - ((velocidadeMS) * deltaT); } float direita = go.centro.x + ((go.largura * proporcaoMetroTela) / 2); if (direita < telaTopLeft.x) { if (go.centro.z == TERCEIRO_PLANO) { adicionarTerceiroPlano++; } else { adicionarSegundoPlano++; } } } NSMutableArray * aRemover = [[NSMutableArray alloc] init]; for (OGBPGameObject * go in adicionados) { float direita = go.centro.x + ((go.largura *
272 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS proporcaoMetroTela) / 2); if (direita < telaTopLeft.x) { [aRemover addObject:go]; } } for (OGBPGameObject * go in aRemover) { [adicionados removeObject:go]; } for (int x=0; x < adicionarSegundoPlano; x++) { [self addNewObject: SEGUNDO_PLANO]; } for (int x=0; x < adicionarTerceiroPlano; x++) { [self addNewObject: TERCEIRO_PLANO]; } } - (void) addNewObject: (float) plano { NSMutableArray * lista = plano == TERCEIRO_PLANO ? terceiroPlano : segundoPlano; int posicao = arc4random() % ([lista count]); OGBPGameObject * go = nil; go = [[lista objectAtIndex:posicao] mutableCopy]; go.idGO = plano == TERCEIRO_PLANO ? 300 : 200; go.centro.x = telaBottomRight.x + ((go.largura * proporcaoMetroTela) / 2); go.visivel = YES; [adicionados addObject:go]; }
A implementação também é muito semelhante à da versão Android. Note como eu obtenho um número aleatório com a função “arc4random()”. E também como uso o método “mutableCopy” para obter clones dos GameObjects. A renderização no método “drawInRect” também ficou semelhante: - (void)glkView:(GLKView rect
*)view
drawInRect:(CGRect)
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 273 { ... // Vamos desenhar os indicadores dinâmicos: [self desenharTextos]; // Vamos renderizar os Game Objects de terceiro plano: for (OGBPGameObject *go in adicionados) { if (go.centro.z == TERCEIRO_PLANO) { [self desenharObjeto:go]; } } // Agora, os de segundo plano: for (OGBPGameObject *go in adicionados) { if (go.centro.z == SEGUNDO_PLANO) { [self desenharObjeto:go]; } } // Finalmente, os de primeiro plano: for (OGBPGameObject *go in cenaCorrente.objetos) { if (go.arquivoTextura != nil && go.visivel) { [self desenharObjeto:go]; } }
Games do tipo plataforma Games “plataforma” são aqueles em que o jogador deve correr e saltar em 2D, movendo-se de uma “plataforma” para outra. Exemplos clássicos são: As séries “Sonic The Hedgehog”, da SEGA, e “Super Mario”, da Nintendo. Embora sejam exemplos antigos, games plataforma ainda são desenvolvidos e vendidos hoje em dia. Temos alguns bons exemplos, citados no filme: “Indie Game: The Movie” (http://www.indiegamethemovie.com/), como: “Super Meat Boy”, da Team Meat (http://supermeatboy.com/), “Braid”, da Number None (http://braid-game.com/) e o sensacional “Fez”, da Polytron Corporation (http:// fezgame.com/) .
274 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Todos os três jogos do filme (“Indie Game: The Movie”) são muito bons e divertidos. Porém, o “Fez”, na minha opinião, é sensacional! O criador, Phil Fish, conseguiu inovar em um game plataforma, pois acrescentou a possibilidade de “girarmos” o game no eixo “y”. Todos os jogos plataforma têm algumas coisas em comum: • São 2D; • O cenário é composto de obstáculos e plataformas, entre as quais o jogador pode “pular”; • Normalmente, a câmera é centrada no PlayerObject, ou em seu entorno. Criar um game plataforma com esse framework que fizemos é bem simples. Para começar, o “PlayerObject” (O GameObject controlado pelo jogador) deve poder saltar sem ficar “quicando”. Podemos conseguir isso zerando o coeficiente de retribuição da nossa configuração. Depois, ele deve ter um tamanho compatível com a tela. Se o criarmos grande demais, teremos dificuldade em fazê-lo saltar entre as plataformas. O cenário de um game plataforma se movimenta em função do PlayerObject (PO), logo, podem existir partes “ocultas” que só aparecem quando o jogador se aproxima. É como “deslizássemos” o “mundo” com a mão, enquanto o observamos através de uma lente. Em nossos exemplos com a bola, sempre usamos “chão”, “teto” e “paredes”, só que sem textura associada. Isso criaria um efeito “fantasmagórico”, pois o PO fica batendo em coisas invisíveis (vamos mostrar isso no exemplo). Então, as plataformas devem ter uma textura associada. E, se possuem textura, nós temos que posicioná-las ao final, depois de calcular seus vértices, mantendo o centro como referência. Outro problema é posicionar a “câmera”... No OpenGL não existe “câmera”, que é apenas uma matriz de transformação que multiplicamos pela matriz de modelo. Quando usamos câmera (gluLookAt), criamos mais um elemento para “atrapalhar” o posicionamento do game. Então, resolvi retirar essa variável da equação, tornando a matriz “câmera” igual a identidade. Simplesmente eu vou manipular a matriz de projeção a cada atualização, focando o centro na posição do nosso PlayerObject. Eu fiz um exemplo simples e você pode usar qualquer modelo. Usei a mesma “bola” que usamos nos exemplos anteriores, alterando seu tamanho e seu coeficiente de retribuição para zero:
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 275 6 2 1 0.0 0.0 0.0 0 0.5 false 0.5 false bola.png 4.0 0.1 0.0
O código-fonte dos exemplos está em: • Android: “..\Codigo\OpenGLAndroid\pargldroid.zip”; • iOS: “..\Codigo\OpenGLiOS\ParGLIOS.zip”; Eis as imagens dos exemplos executando em Android e iOS:
Ilustração 91: Exemplo de game plataforma Android
276 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 92: Exemplo de game plataforma iOS
A principal alteração no código foi modificar a posição dos objetos. Como todos os GameObjects são animados pelo Box2D e possuem textura, eu só posso reposicioná-los após calcular os vértices e, mesmo assim, modificando o centro. E, é claro, eu tive que desligar o filtro de profundidade do OpenGL, como fiz no exemplo anterior, pois dá problema.
Implementação Android Vamos eliminar a “matrizCamera”, que é gerada no final do método “onSurfaceCreated()”, substituindo-a pela matriz identidade: Matrix.setIdentityM(matrizCamera, 0);
E vamos modificar nossa matriz de projeção, no método “onSurfaceChanged()”: Matrix.orthoM(matrizProjecao, 0,
-width width -height height -1, 1);
/ / / /
2, 2, 2, 2,
E no método “update”, eu recalculo a matriz de projeção: float camX = bola.getB2dBody().getTransform().position.x * proporcaoMetroTela; float camY = bola.getB2dBody().getTransform().position.y * proporcaoMetroTela;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 277 Matrix.setIdentityM(matrizProjecao, 0); Matrix.orthoM(matrizProjecao, 0, camX - larguraViewPort/2, camX + larguraViewPort/2, camY - alturaViewPort/2, camY + alturaViewPort/2, -1, 1);
A posição da “câmera” será o centro do meu PlayerObject (a bola), então, eu centralizo a minha projeção nela, tanto na altura, como na largura. Assim, a bola sempre estará no centro da projeção. Um ponto importante é o posicionamento das “plataformas”. Elas são objetos Box2D e OpenGL ES, logo, eu tenho que recalcular os seus centros após criar os vértices. Eu sobrescrevi o método “onSurfaceChanged()” dentro da classe “Renderer” para facilitar as coisas: public class Renderer extends OpenGLAvancadoRenderer {
public Renderer(Context context) throws Exception { super(context); } @Override public void onSurfaceChanged(GL10 gl, int width, int height) { super.onSurfaceChanged(gl, width, height); GameObject plataforma = new GameObject(); plataforma.setId(5); int ix = cenaCorrente.getObjetos(). indexOf(plataforma); plataforma = cenaCorrente.getObjetos().get(ix); plataforma.getCentro().setX(2.5f); plataforma.getCentro().setY(-0.5f); plataforma.getB2dBody().setTransform(new Vec2 (plataforma.getCentro().getX(), plataforma.getCentro().getY()), 0.0f); GameObject chao = new GameObject(); chao.setId(7); ix = cenaCorrente.getObjetos().indexOf(chao); chao = cenaCorrente.getObjetos().get(ix); chao.getCentro().setY(-2.5f);
278 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS chao.getB2dBody().setTransform(new getCentro().getX(), chao.getCentro().getY()), 0.0f); }
Vec2(chao.
Não modifique os centros dos objetos dentro do arquivo de modelo de game (XML), pois isto afetará o posicionamento dos vértices, gerando distorções. Talvez seja melhor alterar o framework para só posicionar os centros após calcular os vértices, mas, neste caso basta posicionar ao final do método “onSurfaceChanged()”. Você terá que construir seu cenário para cada nível, posicionando as plataformas de acordo com seu desenho. Pode até posicionar inimigos ou barreiras em cada uma delas. Para que a bola não caísse no “abismo”, eu mantive as “paredes”, o “teto” e o “chão” dos exemplos anteriores, que são invisíveis (sem textura). Isto cria uma barreira “fantasmagórica” e inexplicável, que mantém a bola presa. Eu mantive os objetos invisíveis para que você veja o efeito em um game final. Tudo tem que ter explicação, logo, a melhor saída é criar um campo de força ou algo do gênero, com a devida textura. Só para finalizar, eu modifiquei o “pulo” do GO, no método “toque”, da classe “Renderer”: Body bola = go.getB2dBody(); Vec2 forca = new Vec2(10.0f * bola.getMass(), 50.0f * bola. getMass()); Vec2 posicao = bola.getWorldCenter().add(new Vec2 (0.0f,0.0f));
Eu diminuí a força aplicada ao eixo “x” e mudei a posição da força para o centro de massa da bola. Em um game real, você vai querer saber a direção e a distância do toque e arrasto, de modo a calcular a força aplicada no pulo.
Implementação iOS Substituir matriz de câmera pela matriz identidade: matrizCamera = GLKMatrix4Identity;
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 279
Modificar nossa matriz de projeção, no método “recalcularAlinhamentos”: matrizProjecao = GLKMatrix4MakeOrtho(-larguraViewPort / matrizProjecao = GLKMatrix -larguraViewPort / matrizProjecao = GLKMatrMakeOho(--alturaViewPort / matrizProjecao = GLKMatrkeOrtho(- alturaViewPort / -1, 1);
2, 2, 2, 2,
Recalcular a matriz de projeção no método “update”: floatcamX=bolaGO.b2dBody->GetPosition().x*proporcaoMetroTela; floatcamY=bolaGO.b2dBody->GetPosition().y*proporcaoMetroTela; matrizProjecao = GLKMatrix4Identity; matrizProjecao = GLKMatrix4MakeOrtho(camX - larguraViewPort / 2, camX + larguraViewPort / 2, camY - alturaViewPort / 2, camY + alturaViewPort / 2, -1, 1);
Posicionar as “plataformas” no final do método “recalcularAlinhamentos”: OGBPGameObject * plataforma = [[OGBPGameObject alloc] init]; plataforma.idGO = 5; int ix = [cenaCorrente.objetos indexOfObject:plataforma]; plataforma = [cenaCorrente.objetos objectAtIndex:ix]; plataforma.centro.x = 2.5f; plataforma.centro.y = -0.5f; plataforma.b2dBody->SetTransform(b2Vec2(plataforma.centro.x, plataforma.centro.y), 0.0f); OGBPGameObject * chao = [[OGBPGameObject alloc] init]; chao.idGO = 7; ix = [cenaCorrente.objetos indexOfObject:chao]; chao = [cenaCorrente.objetos objectAtIndex:ix]; chao.centro.y = -2.5f; chao.b2dBody->SetTransform(b2Vec2(chao.centro.x, chao.centro.y), 0.0f);
Modificar o “pulo” da bola no método “handleTapFrom”:
- (void)handleTapFrom:(UITapGestureRecognizer *)recognizer {
280 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS if (simulando) { b2Body * bolaBody = bolaGO.b2dBody; b2Vec2 forca = b2Vec2(50.0f * bolaBody->GetMass(), 200.0f * bolaBody->GetMass()); b2Vec2 posicao = bolaBody->GetWorldCenter(); bolaBody->SetAwake(true); bolaBody->ApplyForce(forca, posicao); } }
Sistemas de partículas Essa é a última técnica que vamos explicar no livro, antes de entrarmos no projeto exemplo. Porém, nem de longe é a última técnica que existe. A programação de games é muito mais rica e complexa do que eu apresentei ao longo deste livro. Porém, creio que consegui meu objetivo: resumir as principais técnicas, apresentando-as de maneira simples e biplataforma (Android e iOS). Um sistema de partículas é uma simulação computacional formada por vários objetos de proporções diminutas, com o objetivo de representar elementos fluídicos, como: fumaça, explosão, fogo, água, nuvens e até, pasmem, cabelos! Sim, cabelos podem ser representados com um sistema de partículas “com rastro”.
Composição Um sistema de partículas é composto pela própria partícula e por um emissor, que origina diversas partículas, sendo distribuídas de acordo com sua necessidade. Cada partícula possui uma textura acoplada e, em sistemas mais complexos, as partículas podem variar suas texturas e/ou seu brilho ou transparência. O emissor contém dados sobre a quantidade de partículas, sua origem, seus vetores de rota, seus tempos de vida etc. As partículas partem se afastando do objeto que representa a origem. A maneira como partem e seu ângulo são muito importantes. Por exemplo, quando representamos fogo, as partículas tendem a seguir um formato de “cabeça de cometa” (supondo que temos gravidade no ambiente do game), outro exemplo, é quando temos um jato direcional, como um motor de foguete, um fogo de artifício ou uma arma, neste caso, a tendência é um formato de “leque”. Quando temos uma explosão, especialmente em ambiente sem gravidade, a tendência é que as partículas se espalhem em todas as direções.
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 281
Quando temos um jato ou uma explosão, é mais fácil calcular a rota de cada partícula, pois alteramos apenas o ângulo de lançamento. Porém, quando temos alteração na rota, como no caso do fogo, temos que ficar corrigindo a trajetória de cada partícula durante o “update”, preferencialmente, seguindo uma equação de parábola. Outra opção para o fogo é aplicar força variável às partículas, alterando a velocidade angular. A maioria dos Game Engines possui algum tipo de mecanismo para representar sistemas de partículas, o que facilita muito a criação destes tipos de efeitos. Mas não é difícil criar um sistema de partículas simples, adaptando-o de acordo com nossas necessidades. O Box2D é excelente para criar sistemas de partículas, porém, é um grande “ladrão” de FPS! Se você não tiver necessidade de controlar a interação entre as partículas, então não precisa utilizar um engine de física para representar seu movimento. Mesmo que você precise controlar a interação entre as partículas, há a opção de criar a simulação, filmar e transformar em uma animação, utilizando-a ao invés de acionar o sistema real. Isto nos permite manter nossa taxa de FPS e ainda dá um belo efeito visual.
Um exemplo O uso mais comum para sistemas de partículas é representar explosões. Jogos de ação sempre tem algum tipo de explosão e o efeito de um sistema de partículas acrescenta realismo ao game. Vou criar um pequeno exemplo, utilizando nosso framework, para representar um míssil destruindo um asteroide. Embora não seja necessário, vou utilizar Box2D para animar o míssil, pois quero mostrar um exemplo de análise de colisão com Android e iOS, mas as partículas serão animadas manualmente.
282 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 93: Explosão simulada no Android
Ilustração 94: Explosão simulada no iOS
O código fonte dos exemplos está em: • Android: “..\Codigo\OpenGLAndroid\psdroid.zip”; • iOS: “..\Codigo\OpenGLiOS\PSIOS.zip”; Eu criei duas classes: uma para representar o próprio sistema (o emissor) e outra para representar a partícula. Vou utilizar uma única textura e um único vetor de vértices para todas elas, animando apenas a matriz de modelo. Neste caso, a rotação não importa, logo, não vou trabalhar com o ângulo de cada partícula. Em meu exemplo, as partículas deverão ser lançadas em vetores com ângulos variando de 0 até 359 graus, seguindo a trajetória estabelecida no vetor. Cada partícula tem um tempo de vida em segundos e se torna inativa após este
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 283
tempo. O sistema permite fazer “refil” de partículas, representando explosões múltiplas. Fiz algumas modificações no método de atualização do “mundo” e também na renderização. Para começar, eu tenho que saber se houve ou não colisão entre o míssil e o asteroide, e isto é conseguido com um “ContactListener” (b2ContactListener, em C++) do Box2D. Nós já mostramos um exemplo de uso de ContactListener (“..\Codigo\ContactBox2D\contactbox2d.zip”), só que era feito em Java / Swing. Agora, vamos ver como fazer isso em Android e iOS, utilizando OpenGL ES em ambos. Na renderização, eu criei um efeito de “fading”, que faz as partículas “desaparecerem” aos poucos, conforme seu tempo de vida vai terminando. Para isto, tive que alterar o código-fonte do “Fragment Shader” (em linguagem GLSL). Ao rodar o exemplo, você verá que as partículas são emitidas em ângulo variável e aleatório (de 0 a 359 graus), e se afastam do emissor (o asteroide) em velocidade aleatória, desaparecendo aos poucos enquanto se movimentam. Se quiser, pode elimitar a aleatoriedade do ângulo e da velocidade, mas fica meio “artificial”, pois explosões de verdade são “fuzzy” (difusas) e caóticas. Eu repeti a emisão 3 vezes (em cada vez, ele emite menos partículas), mas você pode elimitar isso, se quiser. Efeito de túnel Quando temos um projétil sendo animado em um jogo baseado em FPS, dependendo do seu tamanho e velocidade, pode acontecer dele passar através do alvo, sem atingi-lo. Isso é chamado de “efeito de tunel”. O Box2D procura evitar isso através de um algoritmo chamado CCD (Continuous Collision Detection), de modo a verificar se um objeto dinâmico atravessou um objeto estático. Porém, quando ambos (o projétil e o alvo) são objetos dinâmicos, então temos um problema. Você pode ativar o CCD entre objetos dinâmicos se mudar a propriedade “bullet” do projétil: “bodyDef. bullet = true;”. No meu exemplo, o míssil é um objeto dinâmico e o asteroide é estático. Além disto, a nave é grande e se move devagar, logo, não teremos qualquer problema.
Implementação em Android Vamos começar pelo sistema de partículas, que está no pacote “com.obomprogramador.games.particlesystem”. A classe “Particle” representa uma única
284 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
partícula, e sua implementação não tem mistérios. As principais propriedades são: • private int duracaoSegundos: qual é o tempo máximo de vida dessa partícula em segundos; • private int hTextura: o handler da textura a ser utilizada. Não é o VBO de textura, mas o identificador do buffer de textura que enviamos à GPU; • private int hVertices: o handler do VBO dos vértices do objeto (não varia); • private boolean ativa: se a partícula está ativa, ou seja, ainda dentro do seu tempo de vida; • private float velocidadeX: a velocidade linear da partícula no eixo das abscissas; • private float velocidadeY: a velocidade linear da partícula no eixo das ordenadas; • private Coordenada posicao: a posição atual do centro da partícula, em coordenadas de tela; • private float tempoAtiva: qual é o tempo decorrido desde que esta partícula se tornou “viva”; O sistema de partículas é representado pela classe “ParticleSystem”. Ela possui algumas propriedades que apenas repassa às partículas, como: hVertices, hTextura, duração e centro, mas possui algumas propriedades que controlam o sistema todo: • private boolean parado: se o sistema está parado ou ativo; • private int refilCount: quantas vezes o sistema suporta “refill”; • private int quantidade: qual é a quantidade atual de partículas ativas no sistema; • private int qtdeRefil: qual é a quantidade de partículas a ser regerada; • public int qtdOriginal: qual é a quantidade original de partículas que foi criada; A inicialização de partículas é feita pelo método “criarParticulas()”, que utiliza os parâmetros informados no Construtor do sistema: public void criarParticulas() { for (int x=0; x < this.quantidade; x++) { Particle p = new Particle(random.nextInt(5) + 1, x + 1 + this.refilCount); p.setPosicao(new Coordenada(this.centro.getX(), this.centro. getY(), 0.0f));
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 285 }
int angulo = random.nextInt(360); float radianos = (float) (angulo * Math.PI / 180.0f); float velocidadeX = (float) Math.cos(radianos); float velocidadeY = (float) Math.sin(radianos); p.setVelocidadeX(velocidadeX * (random.nextInt(50) + 1)); p.setVelocidadeY(velocidadeY * (random.nextInt(50) + 1)); p.setDuracaoSegundos(random.nextInt(this.duracaoMaxima+ 3)); p.sethTextura(this.hTextura); p.sethVertices(this.hVertices); p.setAtiva(true); particles.add(p); }
Existem alguns comandos interessantes neste método. Para começar, eu “clono” a coordenada do centro, pois cada partícula terá seu próprio centro sendo alterado a cada atualização (eu tive preguiça de implementar “Cloneable” na classe “Coordenada”). Depois, o ângulo de lançamento de cada partícula é calculado aleatoriamente (entre 0 e 359 graus) e depois transformado em radianos, de modo a calcular o seno e o cosseno, depois eu calculo a velocidade linear em cada eixo, mantendo, assim, a direção da partícula. Só que eu “bagunço” um pouco as velocidades acrescentando mais um número aleatório (entre 1 e 50) em cada velocidade linear. Isso aumenta o “caos” no sistema. Finalmente, eu também vario o tempo de vida da partícula de forma aleatória. O método “refilParticles” acrescenta uma fração das partículas originais e recomeça o sistema. Iniciando a explosão Eu criei uma classe que implementa a interface “ContactListener”, chamada “Contato”, dentro do arquivo “Renderer.java”. O método que mais interessa é o “beginContact()”: @Override public void beginContact(org.jbox2d.dynamics.contacts. Contact c) { if (((Integer)c.getFixtureA().getBody().getUserData()). intValue() == 1 && ((Integer)c.getFixtureB().getBody().getUserData()). intValue() == 2) { atingiu(); } }
286 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Quando eu crio a nave e o asteroide, eu coloco dentro do “UserData” de cada “Body” uma referência para um objeto Integer, que é a propriedade “id” de cada GameObject. Assim, eu crio uma associação entre o Body e o GameObject. Logo, quando ocorre uma colisão, eu posso testar quais objetos estão colidindo. Neste caso, só me interessa se for a nave (id = 1) com o asteroide (id = 2). Eu invoco um método para iniciar o processo de explosão: protected void atingiu() { nave.setVisivel(false); asteroide.setHandlerTextura(asteroidechamas.getHandlerTextura()); emChamas = true; tempoAcumulado = 0.0f; }
Eu torno a nave invisível, mudo a textura do asteroide para uma bola de fogo, ligo o flag indicando que ele foi atingido e zero o acumulador de tempo, que uso para calcular o tempo decorrido desde o início da explosão. Se você quiser, pode criar um sistema de partículas para a explosão da nave (o míssil), mas eu não achei necessário. Depois, o meu Game Loop vai testar se o flag “emChamas” foi ligado, o que significa que tenho que iniciar o sistema de partículas: protected void update(float deltaT) { /* * Atualiza o mundo Box2D e calcula a projeção */ if (simulando) { this.diferencialTempo = deltaT; world.step(deltaT, cenaCorrente.getBox2d().getVelocityInterations(), cenaCorrente.getBox2d().getPositionInterations()); if (emChamas) { if (particleSystem == null) { particleSystem = new ParticleSystem(this.world, 100, new Coordenada( asteroide.getB2dBody().getWorldCenter().x * proporcaoMetroTela, asteroide.getB2dBody().getWorldCenter().y * proporcaoMetroTela), 0.2f,
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 287 bolafogo.getHandlerTextura(), bolafogo.getVobVertices(), 2,3); particleSystem.setParado(false); } else { tempoAcumulado += deltaT; particleSystem.update(deltaT); } } } }
Se a instância do ParticleSystem não tiver sido criada, eu vou criá-la, passando: • A instância do “mundo” Box2D (só é necessário se você resolver animar as partículas com o Box2D); • A quantidade de partículas a serem criadas; • As coordenadas do emissor das partículas; • O diâmetro de cada partícula (em valores do “mundo” e não de tela); • O identificador da textura a ser usada nas partículas; • O identificador do VBO de vértices a ser utilizado; • A duração máxima das partículas, em segundos; • O número de vezes de “refill” do sistema. Cada vez que zerar a quantidade de partículas ativas, ele vai fazer “refill”. Caso o sistema já tenha sido criado, eu acumulo o tempo decorrido desde o início da explosão, e atualizo a posição das partículas: public void update(float deltaT) { for (Particle p : this.particles) { if (p.isAtiva()) { p.getPosicao().setX( p.getPosicao().getX() + (p.getVelocidadeX() * deltaT) ); p.getPosicao().setY( p.getPosicao().getY() + (p.getVelocidadeY() * deltaT) ); } } }
288 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Renderização das partículas Bem, antes de entrarmos no código que renderiza as partículas, deixe-me lembrar que eu criei um efeito de “Fading”, de modo que as partículas se tornem mais transparentes, na medida em que vão “morrendo”. Isto é possível diminuindo o valor do canal alfa (opacidade) da imagem. Pixeis renderizados com alfa igual ou próximos de 1, são mais opacos, já os com alfa igual ou próximos de zero, são transparentes. Um pixel transparente deixa aparecer a imagem que está no fundo da tela. Aumentar a transparência de uma imagem é diminuir o valor de alfa para seus pixels. E quem mexe com as cores dos pixels? O Fragment Shader! Eu tive que alterar o código do meu Fragment Shader para incluir um “uniform” (uma constante que eu passo para ele): precision mediump float; varying vec2 vTextureCoord; uniform sampler2D sTexture; uniform float fadefactor; void main() { gl_FragColor = texture2D(sTexture, vTextureCoord); gl_FragColor.a *= fadefactor; }
Lembre-se que o Fragment Shader é um programa, escrito na linguagem GLSL, e que eu guardo em um String e uso na hora de montar o programa da GPU. O que estou fazendo? Eu declarei um “uniform”, que é um parâmetro que eu passo para o Shader. A diferença entre “uniform” e “attribute” é que o primeiro não varia entre as chamadas do Shader para uma única operação de desenho. Como o fator de esvanecimento (fadefactor) que vou usar é o mesmo para todos os pixels da imagem, posso usar um “uniform”. A variável “gl_FragColor” é global (“built-in”) no GLSL e seu tipo é “vec4” (x,y,z e a), onde o campo “a” representa o valor do canal alfa. Ao multiplicar o valor de alfa pelo fator, eu vou modificar a transparência do pixel. Se o fator for próximo de 1, o pixel ficará mais opaco, se for menor que 1, o pixel ficará mais transparente. Então, antes de renderizar qualquer coisa em meu programa, eu preciso passar o valor de “fadefactor” que o Fragment Shader vai usar, e isto é feito com o método “glUniform1f”, que passa um falor “float” para um parâmetro “uniform”. Veja os exemplos abaixo:
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 289
• “GLES20.glUniform1f(maFadeFactor, (float) 1.0)”: estou passando “fadefactor” 1, logo, a imagem será totalmente opaca; • “GLES20.glUniform1f(maFadeFactor, (float) 0.0)”: estou passando “fadefactor” zero, logo, a imagem será totalmente transparente; • “GLES20.glUniform1f(maFadeFactor, (float) percentalfa)”: estou passando “fadefactor” variável; Se eu estou passando um “uniform” para o Fragment Shader, eu preciso saber qual é o seu identificador, então, após “linkeditar” o programa da GPU, eu obtenho um idendificador para o “uniform”, o que é feito no método “onSurfaceCreated()”: maFadeFactor = GLES20.glGetUniformLocation(programaGLES, “fadefactor”);
O nome que eu passo para o método “glGetUniformLocation” deve ser o mesmo nome da variável dentro do Shader. Este processo é o mesmo utilizado para passar a Matriz Model-View-Projection para o Vertex Shader: muMVPMatrixHandle = GLES20.glGetUniformLocation(programaGLES, “uMVPMatrix”); ... GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, matrizIntermediaria, 0);
Fora o “Fading”, eu precisava pegar cada partícula, verificar se estava ativa, renderizá-la e testar se ainda existiriam mais partículas ativas no sistema. Eu sobrescrevi o método “onDrawFrame()” na classe “Renderer.java”: @Override public void onDrawFrame(GL10 gl) { super.onDrawFrame(gl); if (this.emChamas) { int contagem = 0; for (Particle p : particleSystem.particles) { if (p.isAtiva()) { if (p.getDuracaoSegundos() < p.getTempoAtiva()) { p.setAtiva(false); continue; } contagem++;
290 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS p.setTempoAtiva(p.getTempoAtiva() + this.diferencialTempo); GLES20.glActiveTexture(GLES20.GL_TEXTURE0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, p.gethTextura()); // Vértices: GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, p.gethVertices()); GLES20.glVertexAttribPointer(maPositionHandle, 3, GLES20.GL_FLOAT, false,12, 0); GLES20.glEnableVertexAttribArray(maPositionHandle); // Textura: GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, this.hTextureVOB); checkGlError(“glEnableVertexAttribArray maPositionHandle”); GLES20.glVertexAttribPointer(maTextureHandle, 2, GLES20. GL_FLOAT, false, 8, 0); GLES20.glEnableVertexAttribArray(maTextureHandle); // Matriz de posicionamento da textura, sem rotação nem movimento: Matrix.setIdentityM(matrizModelo, 0); float posicaoX = p.getPosicao().getX(); float posicaoY = p.getPosicao().getY(); Matrix.translateM(matrizModelo, 0, posicaoX, posicaoY, 0); Matrix.multiplyMM(matrizIntermediaria, 0, matrizCamera, 0, matrizModelo, 0); Matrix.multiplyMM(matrizIntermediaria, 0, matrizProjecao, 0, matrizIntermediaria, 0); // Calculamos o “fading”: float percentalfa = 1.0f - (p.getTempoAtiva() / p.getDuracaoSegundos());
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 291 GLES20.glUniform1f(maFadeFactor, (float) percentalfa); GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, matrizIntermediaria, 0); GLES20.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, QUANTIDADE_DE_VERTICES); checkGlError(“glDrawArrays”); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); } } if (emChamas) { if (contagem == 0) { emChamas = false; particleSystem = null; asteroide.setVisivel(false); } else { if (contagem < (particleSystem.qtdOriginal / 3)) { particleSystem.refilParticles(); } } } } }
Eu testo se a particula está ativa e, caso esteja, calculo o tempo decorrido. Se for maior que sua duração, então eu a desativo e não a desenho mais. Caso ainda esteja ativa, eu desenho a partícula informando um fator de esvanecimento calculado com base no tempo de vida. Quanto mais tempo a partícula estiver “viva” menor será o fator, tornando-a mais transparente. Após percorrer todas as partículas, eu preciso saber a quantidade de partículas ativas que restou no sistema. Se não sobrou partícula alguma, eu desligo o sistema e torno o asteroide invisível. Caso contrário, se a quantidade de partículas restantes for menor que 1/3 do original, eu disparo mando o sistema fazer um “refill” de partículas.
Implementação iOS Bem, agora está na hora de largar tudo, sentar em posição de lótus e ficar repetindo “OOMMMM”... A implementação iOS é um pouco mais “hardcore”
292 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
que a implementação Android. Não é nada de outro mundo, mas é um pouco mais complicada. Para começar, não podemos usar o GLKBaseEffect, que nos isolava dos problemas mundanos do Open GL ES. O motivo é que o Shaders utilizados pelo GLKBaseEffect não nos atendem. Como o resto é muito parecido com o da implementação Android, só vou focar na parte principal, que é alterar o projeto para renderizar diretamente com o OpenGL ES, dispensando o GLKBaseEffect. Sugiro que você compare a versão iOS com a versão Android. Criando Shaders Eu sugiro que você comece criando um novo projeto no Xcode, utilizando o template “OpenGL Game”. Depois, mantenha esse código à mão para poder copiar alguns métodos, além do próprio código-fonte dos Shaders. Crie um novo projeto, baseado no “ParalaxIOS”, que fizemos recentemente, pois teremos objetos que não são animados com o Box2D. Copie, do projeto template, os dois arquivos que estão dentro da pasta “Shaders” (“Shader.vsh” e “Shader.fsh”). Pode até copiar a pasta inteira. Depois, temos que informar ao Xcode que estes arquivos devem ser empacotados junto com os outros recursos do projeto: 1. Clique no “Target” do projeto; 2. Selecione “Build Phases”; 3. Selecione “Copy bundle resources”; 4. Adicione seus arquivos Shaders. Se não fizer isto, os Shaders não estarão disponíveis quando o projeto for executado. Bem, eu sugiro que substitua o código-fonte dos dois Shaders pelos que utilizamos na versão Android: Vertex Shader (Shader.vsh):
uniform mat4 uMVPMatrix; attribute vec4 aPosition; attribute vec2 aTextureCoord; varying vec2 vTextureCoord; void main() { gl_Position = uMVPMatrix * aPosition; vTextureCoord = aTextureCoord; }
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 293
Fragment Shader (Shader.fsh): precision mediump float; varying vec2 vTextureCoord; uniform sampler2D sTexture; uniform float fadefactor; void main() { gl_FragColor = texture2D(sTexture, vTextureCoord); gl_FragColor.a *= fadefactor; }
Você poderia utilizar os mesmos Shaders que o template Xcode gerou, só que os nomes dos atributos seriam diferentes. Bem, temos os Shaders, agora precisamos: 1. Compilar os Shaders; 2. Linkeditar; 3. Obter as referências para os atributos e uniforms. Felizmente, o template gerado pelo Xcode (OpenGL Game) já tem tudo isso. Abra o arquivo do View Controller que ele criou e copie para o seu próprio View Controller os métodos: - (BOOL)loadShaders; - (BOOL)compileShader:(GLuint *)shader type:(GLenum) type file:(NSString *)file; - (BOOL)linkProgram:(GLuint)prog; - (BOOL)validateProgram:(GLuint)prog;
E, no método “viewDidLoad”, invoque o método “loadShaders”: - (void)viewDidLoad { [super viewDidLoad];
// Alocamos o contexto OpenGL ES self.context = [[EAGLContext alloc] initWithAPI:k EAGLRenderingAPIOpenGLES2]; if (!self.context) { NSLog(@”Failed to create ES context”); } view = (GLKView *)self.view; view.context = self.context;
294 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS [EAGLContext setCurrentContext:view.context]; [self loadShaders];
Tem que ser exatamente após indicarmos qual é o contexto OpenGL ES corrente. Obtendo indicadores dos parâmetros dos Shaders Bem, temos várias informações que devem ser passadas para os Shaders: • uniform mat4 uMVPMatrix: matriz Model-View-Projection; • attribute vec4 aPosition: coordenadas da posição que estamos renderizando; • attribute vec2 aTextureCoord: coordenadas de textura da posição; • uniform float fadefactor: o nosso fator de esvanecimento; Para passar parâmetros aos Shaders, primeiramente, é necessário obter indicadores (handlers) para cada um deles. Os atributos () devem ser indicados ANTES de linkeditar o programa, dentro do método “loadShaders”, após adicionarmos os Shaders compilados ao programa GPU que estamos criando: • glBindAttribLocation(_program, GLKVertexAttribPosition, “aPosition”); • glBindAttribLocation(_program, GLKVertexAttribNormal, “aTextureCoord”); A diferença entre “attribute” e “uniform” é que os primeiros variam a cada chamada do Shader, mesmo dentro de uma única operação de renderização. Depois, linkeditamos o programa final, cujo indicador está na variável “_ program”. Então, temos que pegar o indicador de localização dos atributos e uniforms, o que é feito no mesmo método, após a linkedição do programa: uniforms[UNIFORM_MODELVIEWPROJECTION_MATRIX] = glGetUniformLocation(_program, “uMVPMatrix”); uniforms[UNIFORM_FADE_FACTOR] = glGetUniformLocation(_program, “fadefactor”); maPositionHandle = glGetAttribLocation(_program, “aPosition”); maTextureHandle = glGetAttribLocation(_program, “aTextureCoord”);
Eu não gostei muito de usar um vetor para armazenar os indicadores dos uniforms, mas é assim que o template usa e resolvi não mudar. Renderização das partículas No método “drawInRect” temos mudanças significativas. Para começar, não vamos mais utilizar o GLKBaseEffect, logo, tudo relacionado com a variável “self.effect” deve ser substituído. Se você observar a versão Android, saberá o que está faltando. Para começar, vamos usar o programa GPU que criamos:
Capítulo 8 - ������������������������������ Técnicas Comuns em Games������ — 295 - (void)glkView:(GLKView *)view drawInRect:(CGRect)rect { // Limpar e preparar: glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glUseProgram(_program); ...
Ele inclui os nossos Shaders e atributos. Em segundo lugar, ao invés de usar as propriedades “self.effect.texture2d0. xxx”, temos que invocar funções do OpenGL ES para indicar qual será a textura ativa: glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, cenaCorrente.glProps.textureInfo.name);
Eu ainda estou utilizando as classes: GLKTextureInfo e GLKTextureLoader, logo, eu armazeno as texturas utilizando uma instância de GLKTextureInfo e a propriedade “name” contém o indicador da textura a ser utilizada. Outros detalhes que mudam são os atributos de textura e vértices. Com o GLKBaseEffect, nós usávamos os indicadores que ele nos formecia: glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 3, BUFFER_OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribPosition); glBindBuffer(GL_ARRAY_BUFFER, _textureBuffer); glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 2, BUFFER_OFFSET(0)); glEnableVertexAttribArray(GLKVertexAttribTexCoord0);
Agora, temos outros atributos para vértice e textura:
glVertexAttribPointer(maPositionHandle, 3, GL_FLOAT, GL_ FALSE, sizeof(GLfloat) * 3, BUFFER_OFFSET(0)); glEnableVertexAttribArray(maPositionHandle); glBindBuffer(GL_ARRAY_BUFFER, _textureBuffer); glVertexAttribPointer(maTextureHandle, 2, GL_FLOAT, GL_ FALSE, sizeof(GLfloat) * 2, BUFFER_OFFSET(0)); glEnableVertexAttribArray(maTextureHandle);
Finalmente, antes de invocar a operação de desenho, temos que passar os nossos uniforms de matriz e de fator: glUniformMatrix4fv(uniforms[UNIFORM_MODELVIEWPROJECTION_ MATRIX], 1, 0, matrizIntermediaria.m); glUniform1f(uniforms[UNIFORM_FADE_FACTOR], 1.0f); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
296 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Conclusão Existem milhares de técnicas e formas de implementação diferentes. As que demonstrei nesse capítulo nem sempre são as melhores, porém, são fáceis de entender, simples de implementar e eficientes. Eu recomendo que você rode os exemplos, altere e use bastante, criando seu próprio framework de games.
Capítulo 9 Vamos Criar um Game Seu objetivo ao ler este livro é criar um game, certo? Então, para facilitar as coisas e reunir tudo o que vimos até agora, vou mostrar um game bem simples, porém interessante: “Bola no Quintal”.
Ilustração 95: A tela inicial do game
Você terá acesso ao código-fonte completo da versão inicial do Game, com três níveis. Eu estou finalizando a versão final, que será publicada nos principais mercados de aplicações móveis: Google Play e AppStore. Em outras palavras, você verá o projeto e construção de um game de verdade, em duas plataformas diferentes, utilizando as ferramentas e o pequeno framework que mostrei no livro. O jogo é simples e fácil de jogar, porém apresenta desafios crescentes ao jogador. “Bola no Quintal” é como aquelas brincadeiras antigas, que eu fazia quando era criança. O que você faz quando tem apenas uma bola e algumas garrafas velhas? Pode brincar de tentar derrubar as garrafas! Isso resume o game.
298 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Ilustração 96: Um jogo simples e fácil
Você tem um cenário, com um muro dividindo duas áreas. Na parte esquerda, ficam você e a bola e, na parte direita, alguns objetos equilibrados sobre pedaços de madeira. Você deve jogar a bola e derrubar os objetos com ela. Sempre que ela toca o chão, volta para você jogar novamente. No primeiro nível, você deve apenas derrubar os objetos, no menor tempo possível. Nos outros níveis, você deverá derrubá-los em uma determinada ordem. Além disto, as bolas vão ficando mais pesadas e vazias nos níveis superiores, o que faz com que seja mais difícil acertar. Eu criei arquivos de recursos com idioma Inglês e localização para Português. O motivo é simples: se eu não tiver recursos no idioma do jogador, mostrarei a versão em Inglês. O jogo armazena os menores tempos de conclusão de cada nível. Em uma versão comercial futura, ele vai compartilhar isso através de redes sociais de games, como o Game Center, da Apple. Cada nível é definido em seu próprio arquivo XML de modelo, e contém um tempo limite para conclusão. Se o jogador não conseguir derrubar todos os objetos neste tempo, aparece uma imagem informando que ele perdeu. Por que não um jogo de tiro? Na verdade, um jogo de tiro, como o “AsteroidNuts” (mostrado no início), seria até mais simples. Porém, é bem comum. Eu quis fazer um game que usasse bem os recursos do livro, e o “Bola no Quintal” atende a esse objetivo. Na verdade, o framework se adapta muito bem a jogos de bola. Finalmente, outra grande vantagem é a atratividade para “Casual Gamers”, crianças
Capítulo 9 - Vamos Criar um����������� Game������ — 299
etc. Um jogo de bola agrada a todos, porém, existem pessoas que não curtem “Shooters”.
Limitações O objetivo deste trabalho é fornecer um conjunto de ferramentas e técnicas, com exemplo de aplicação em código-fonte, para que você construa games móveis para Android e iOS. Logo, o game que vou mostrar é apenas um protótipo funcional, que emprega quase tudo o que vimos no livro. Ele só tem três níveis, embora seja muito expandir isso para 30 ou mais. O objetivo é mostrar como criar níveis e as diferenças de dificuldade entre eles. O game também carece de efeitos sonoros e música. Resolvi deixar o game o mais simples possível para apresentação no livro. Todas as ilustrações foram compostas em parte (ou todo) com base em imagens do site OpenClippart.org, logo, não são imagens para uso comercial. Finalmente, outra coisa que deixei de fora foi o “Social gaming”, ou seja, o uso de uma rede social de games, como o Game Center, da Apple, ou o OpenFeint. Embora o compartilhamento de conquistas seja importante, eu preferi deixar de fora, pois foge ao escopo do livro. Resumindo, o game é funcional e perfeitamente jogável, porém, não é um produto acabado, pronto para o mercado.
Licença de uso do código-fonte Todo o código-fonte do livro é liberado sob a licença Apache, versão 2.0, que pode ser lida em: http://www.apache.org/licenses/LICENSE-2.0.html. Em resumo, esta licença lhe dá permissão para usar, copiar e criar trabalhos derivados do código-fonte, desde que mantenha a referência para o autor original (além de uma cópia da licença original). Um trabalho derivado é a criação de um produto que usa o código-fonte licenciado. Para todo o código-fonte do livro, isso não é problema algum, desde que você deixe uma indicação de que você utilizou partes do código-fonte do meu livro. Porém, no caso do código-fonte do game, eu modifiquei ligeiramente os termos da licença, pois eu tenho uma versão comercial deste mesmo game. Você pode fazer o que quiser com o código-fonte do Game, exceto uma coisa: criar um game derivado dele. Se você quiser criar um game “Bola de Quintal” melhorado (ou mesmo com outro nome), deve solicitar licença para
300 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
isto. O motivo é simples: eu tenho uma versão comercial do game, da qual retirei o código-fonte que foi compartilhado. Você pode e deve criar um game usando o código-fonte do “Bola no Quintal”, desde que seja um jogo diferente. Por exemplo, um jogo de Voleibol. Licença para uso do código-fonte do Game “Bola no Quintal” 1. O alvo desta licença é o projeto “Bola no Quintal”, composto pelo código-fonte, imagens e outros recursos, que estão “compactados” dentro dos seguintes arquivos: • Versão Android: “byballdroid.zip”; • Versão iOS: “ByBall.zip”; 2. O código-fonte, os arquivos de configuração e os recursos estão liberados para uso e cópia, de acordo com a licença Apache 2.0 (http:// www.apache.org/licenses/LICENSE-2.0.html), sendo sujeitos a restrições adicionais, descritas nesta licença; 3. Você pode fazer tudo o que a licença Apache 2.0 permite, porém, ao criar um produto derivado, ou seja, um game semelhante a ele, deve solicitar licença do autor (eu, Cleuton Sampaio);
A concepção A ideia de fazer um jogo como o “Bola no Quintal” surgiu antes de eu começar a escrever o livro. Eu gosto particularmente de jogos de bolas, prova disto é que, no meu livro anterior, eu criei o “BueiroBall” (que também vai ser lançado no mercado em breve). Porém, quando eu estava experimentando com o Box2D, criei alguns efeitos interessantes, como o da próxima figura.
Ilustração 97: Inspiração para o “Bola no Quintal
Capítulo 9 - Vamos Criar um����������� Game������ — 301
Eu fui elaborando o game e, quando chegou no capítulo 7, eu já tinha muita coisa do game pronta. Técnicas 2D são muito bem aproveitadas em jogos casuais, como o “Bola no Quintal”, logo, tudo se encaixa perfeitamente.
Jogos casuais Jogos casuais são para pessoas comuns, que jogam apenas em determinados momentos, como: antes de dormir, na fila de espera ou então no ônibus. Ao contrário de jogos 3D, que exigem muita atenção do jogador, eles são feitos para divertir e passar o tempo. Um bom exemplo é o famoso “Angry Birds”, que conquistou até quem não gosta de games. Recentemente, eu comprei o game do filme “Detona Ralph” (Wreck It Ralph), para o iPad, e os vários minigames que ele contém são realmente divertidos e viciantes. O jogo deve ser fácil de jogar, mas isto não quer dizer que ele deva ser fácil de finalizar. O jogador, geralmente, gosta de níveis crescentes de dificuldade. O próprio “Angry Birds” é assim. Então, eu pensei que o jogo deve ser simples, com jogabilidade fácil e possível de rodar em um smartphone ou em um tablet.
Jogabilidade O jogo é simples: você tem que “chutar” a bola, tocando-a e arrastando-a até onde quiser. O ângulo do arrasto e a sua distância determinarão a direção e a força aplicadas na bola. Confesso que fiquei preocupado com este tipo de ação em um Smartphone, no qual a bola ficaria bem pequena, porém, depois dos primeiros testes, constatei que não é problema. Eu havia pensado em acionar a bola de outras formas, como usando um acelerômetro, porém, jogadores de smartphone, geralmente, não gostam muito de ficar “balançando” o aparelho. Também pensei em um sistema mais completo, onde o jogador indicaria o ponto de toque na bola e o ângulo, indicando a força em um sensor (como os de jogos de golfe), mas ficaria “chato” para a maioria dos jogadores casuais. O resultado ficou bom, sendo que eu testei com várias pessoas, de idades diferentes. É claro que o pessoal mais velho tem certa dificuldade para jogar em smartphones, porém apresentam a mesma dificuldade com outros jogos, como o “Angry Birds”, logo, para estas pessoas é melhor o uso de um tablet.
302 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Como usei o próprio framework que desenvolvi até aqui, eu consigo controlar o FPS do game, evitando que ele “acelere” em dispositivos mais rápidos. Da mesma forma, as imagens são redimensionadas de acordo com o tamanho da diagonal da tela, sendo geradas usando “Mipmaps”, o que resulta em escalas muito boas.
Implementação Básica Vamos ver aqui as alterações básicas que fiz para ambas as versões. • A versão Android está em: “..\Codigo\GameDroid\byballdroid.zip”. • A versão iOS está em: “..\Codigo\GameIOS\ByBall.zip”; Sugiro que você carregue um ou o outro projeto e estude a implementação.
I18N e L10N Internacionalização (I18N) e Localização (L10N) são aspectos fundamentais de games. Neste game, eu já utilizei o mecanismo de internacionalização do Android e do iOS, criando a localização default em Inglês, acrescentando Português como localização opcional. No Android, é feito assim: • Strings: colocamos os textos em Inglês dentro de: “res/values/strings.xml” e a versão em Português dentro de: “res/values-pt/strings. xml”; • Imagens: colocamos as imagens em Inglês, ou as que não tem idioma, dentro de: “res/drawable-mdpi”, e as que contém texto em Português dentro de: “res/drawable-pt-mdpi”; No iOS, é bem mais complicado. Primeiramente, como o Game é Universal, temos que criar dois Storyboards: um para iPhone e outro para iPad. Depois, temos que repetir o mesmo layout em ambos, incluindo todas as views e segues. A localização de strings é fácil: 1. Crie um arquivo “Localizable.strings”; 2. No painel de propriedades (lado direito), selecione “Identity” e adicione as duas localizações: Português e Inglês; Para as imagens que devam ser localizadas, faça a mesma coisa. Os recursos localizados ficam em pastas separadas: “en.lproj” (Inglês) e “pt-lproj” (Português).
Capítulo 9 - Vamos Criar um����������� Game������ — 303
Alterações no modelo do game Para começar, incluí alguns Tags novos no XML do game: 7 true 2 0 2 2 2 0 6 8 0.5 false 0.15 false garrafa1.png 2.0 0.1 0.2
Assim como incluí os novos Tags, fiz as alterações necessárias na classe que armazena o GameObject e na classe que lê o XML: • Android: GameObject.java e GameModelLoader.java; • iOS: OGBPGameObject (.h e .m) e OGBPGameModelLoader (.h e .m). Vamos ver o que estes atributos significam: • Visivel: se o GameObject deve ser renderizado; • GameObject: 1 – GAMEOBJECT_PLAYER, 2 – GAMEOBJECT_NPC e 3 – GAMEOBJECT_CENARIO; • Ordem: a ordem de “derrubada”. Antes de derrubar um objeto de determinada “ordem”, todos os objetos da “ordem” anterior devem ter sido derrubados; • Alinhar: introduzi a opção 6 – ALINHAMENTO_SOBRE_OUTRO, que indica que este GameObject deverá ficar com sua base sobre outro GameObject;
304 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
• Sobre: o “id” do GameObject sobre o qual este GO deverá ficar; Esta nova opção de alinhamento me permite posicionar uma garrafa sobre um poste ou sobre o muro. E também alterei o objeto “”, para incluir o TAG “”, indicando o limite de tempo, em segundos, para o jogador completar este nível. Logo após carregar o modelo, eu crio um vetor de quantidade de objetos por “ordem”: Android: Classe “Renderer.java”, método “onSurfaceChanged()”: int maxOrdem = 0; for (GameObject go : cenaCorrente.getObjetos()) { if (go.getOrdem() > maxOrdem) { maxOrdem = go.getOrdem(); } if (go.getGameObject() == GameObject.GAMEOBJECT_ NPC) { this.quantidadeNPC++; } if (go.getAlinhamento() == GameObject.ALINHAMENTO_GO.ALINHAMENTO_BASE_ CHAO) { float yChao = -1 * ((this.alturaViewPort / 2) / proporcaoMetroTela); yChao = yChao + go.getAltura() / 2.0f; go.getCentro().setY(yChao); go.getB2dBody().setTransform(new Vec2(go.getCentro().getX(), go.getCentro().getY()), 0.0f); } } // A ordem começa em zero this.qtdOrdem = new int[maxOrdem + 1];
iOS: Classe “OGCTViewController.m”, método “
int maxOrdem = 0; for (OGBPGameObject *go in cenaCorrente.objetos) { if (go.ordem > maxOrdem) { maxOrdem = go.ordem; } if (go.gameobject == GAMEOBJECT_NPC) { quantidadeNPC++;
Capítulo 9 - Vamos Criar um����������� Game������ — 305 } if (go.alinhamento == ALINHAMENTO_BASE_CHAO) { float yChao = -1 * ((alturaViewPort / 2) / proporcaoMetroTela); yChao = yChao + go.altura / 2.0f; go.centro.y = yChao; go.b2dBody->SetTransform(b2Vec2(go.centro.x, go.centro.y, 0.0f); } } qtdOrdem = [[NSMutableArray alloc] init]; if (maxOrdem == 0) { [qtdOrdem addObject:[NSNumber numberWithInt:0]]; } else { for (int x=0; x<=maxOrdem; x++) { [qtdOrdem addObject:[NSNumber numberWithInt:0]]; } }
Agora, o alinhamento “um-sobre-o-outro” é resolvido logo depois: Android:
for (GameObject go : cenaCorrente.getObjetos()) { if (go.getGameObject() == GameObject.GAMEOBJECT_ NPC) { this.qtdOrdem[go.getOrdem()]++; } if (go.getAlinhamento() == GameObject.ALINHAMENTO_ GO.ALINHAMENTO_SOBRE_OUTRO) { GameObject goBase = new GameObject(); goBase.setId(go.getSobre()); int idBase = cenaCorrente.getObjetos(). indexOf(goBase); goBase = cenaCorrente.getObjetos(). get(idBase); float yChao = goBase.getCentro().getY() + goBase. getAltura() / 2;
306 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS yChao = yChao + go.getAltura() / 2.0f; go.getCentro().setY(yChao); go.setyOriginal(go.getCentro().getY()); go.setxOriginal(go.getCentro().getX()); go.getB2dBody().setTransform(new Vec2(go. getCentro().getX(), go.getCentro().getY()), 0.0f); } }
iOS: for (OGBPGameObject * go in cenaCorrente.objetos) { if (go.gameobject == GAMEOBJECT_NPC) { int qtd = [[qtdOrdem objectAtIndex:go.ordem] intValue]; qtd++; [qtdOrdem setObject:[NSNumber numberWithInt:qtd] atIndexedSubscript:go.ordem]; } if (go.alinhamento == ALINHAMENTO_SOBRE_OUTRO) { OGBPGameObject * goBase = [[OGBPGameObject alloc] init]; goBase.idGO = go.sobre; int idBase = [cenaCorrente.objetos indexOfObject:goBase]; goBase = [cenaCorrente.objetos objectAtIndex:idBase]; float yChao = goBase.centro.y + goBase.altura / 2; yChao = yChao + go.altura / 2.0f; go.centro.y = yChao; go.yOriginal = go.centro.y; go.xOriginal = go.centro.x; go.b2dBody->SetTransform(b2Vec2(go.centro.x, go.centro.y), 0.0f); } }
Capítulo 9 - Vamos Criar um����������� Game������ — 307
Alterações na carga do nível corrente Agora, o jogador pode escolher qual nível quer jogar. Isto é feito na tela anterior, através de três botões em formato de bolas. Ao clicar em um dos botões, a tela seguinte (do Game) é carregada com o parâmetro, indicando qual foi o nível selecionado. No Android, fazemos isto acrescentando atributos ao “Intent” que invoca a próxima tela (classe “MainActivity.java”, método: “selecionar()”: public void selecionar(View view) { Intent i = new Intent (this.getApplicationContext(), GameActivity.class); int nivel = 0; switch(view.getId()) { case R.id.btnnivel1: nivel = 1; i.putExtra(“modelogame”, “modelonivel1.xml”); i.putExtra(“modelotela”, “tela.xml”); break; case R.id.btnnivel2: nivel = 2; i.putExtra(“modelogame”, “modelonivel2.xml”); i.putExtra(“modelotela”, “tela.xml”); break; case R.id.btnnivel3: nivel = 3; i.putExtra(“modelogame”, “modelonivel3.xml”); i.putExtra(“modelotela”, “tela.xml”); break; } i.putExtra(“nivel”, nivel); this.startActivity(i); }
No iOS, eu utilizei uma “segue” que vai de cada botão de nível até o nosso ViewController de game. Então, eu uso o método “prepareForSegue” para alterar propriedades no ViewController do game: -(void)prepareForSegue:(UIStoryboardSegue sender:(id)sender {
*)segue
308 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS if ([segue.identifier isEqualToString:@”nivel1”]) { OGCTViewController *destViewController = segue. destinationViewController; destViewController.nomeTela = @”tela”; destViewController.nivel = 1; destViewController.nomeGame = @”modelonivel1”; } else if ([segue.identifier isEqualToString:@”nivel2”]) { OGCTViewController *destViewController = segue. destinationViewController; destViewController.nomeTela = @”tela”; destViewController.nivel = 2; destViewController.nomeGame = @”modelonivel2”; } else if ([segue.identifier isEqualToString:@”nivel3”]) { OGCTViewController *destViewController = segue. destinationViewController; destViewController.nomeTela = @”tela”; destViewController.nivel = 3; destViewController.nomeGame = @”modelonivel3”; } else if ([segue.identifier isEqualToString:@”ajuda”]) { BYBLTextoViewController *destViewController = segue.destinationViewController; destViewController.isAjuda = YES; } else { BYBLTextoViewController *destViewController = segue.destinationViewController; destViewController.isAjuda = NO; } }
Note que eu tenho duas outras “segues”: uma para o botão “ajuda” e outra para o botão “pontos”, que mostram, respectivamente, o “help” da aplicação e a pontuação do jogador. Eu separei o modelo de game em arquivos diferentes, embora pudesse colocar várias cenas em cada arquivo, considerei que seria mais simples. No
Capítulo 9 - Vamos Criar um����������� Game������ — 309
momento de carregar o modelo de game (e o modelo de tela), eu recebo o nome de cada arquivo (e o nível), carregando o modelo correspondente. No Android, eu recebo os atributos que vieram no “Intent” e passo para o construtor da classe “Renderer”: Intent i = this.getIntent(); int cena = i.getIntExtra(“nivel”, 0); String nomeModeloGame = i.getStringExtra(“modelogame”); String nomeModeloTela = i.getStringExtra(“modelotela”); renderer = new Renderer(this.getApplicationContext(), nomeModeloGame, nomeModeloTela);
Depois, carregamos o modelo do game e da tela utilizando os nomes informados. Também alteramos a carga da cena, para informar o número do nível desejado. No iOS já alteramos as propriedades do View Controller.
Alterações no Game Loop O Game Loop tem que verificar o tempo decorrido, assim poderá terminar o nível, caso o usuário não tenha conseguido derrubar os NPCs. A cada “update”, eu verifico se algum NPC está com altura inferior a original (se foi atingido). Se estiver, eu verifico se todos os objetos de ordem inferior foram derrubados, caso contrário, eu mostro a imagem da “cara triste”. Se todos os NPCs forem derrubados, o nível acabou. Então, eu verifico se o tempo foi menor que o já registrado para aquele nível e armazeno. Android: Classe “Renderer.java”, método: “update()”: protected void update(float deltaT) { super.update(deltaT); if (!acabou) { segundosDeJogo += deltaT; } if (segundosDeJogo >= cenaCorrente. getLimitesegundos()) { // O jogo acabou... this.tempoAviso = 3; this.resetBola();
chutou = true; trocarAviso(15, true);
310 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS acabou = true; } if (this.aviso != null) { tempoAviso -= deltaT; if (tempoAviso <= 0) { trocarAviso(0, true); } } tratarColisaoNPC(); if (resetarBola) { this.resetBola(); } }
O método “tratarColisaoNPC()” verifica se algum NPC caiu e se está na ordem certa. IOS: Classe “OGCTViewController.m”, método: “update”: - (void)update { float deltaT = [self timeSinceLastUpdate]; if(self.pausa) { return; } if (!simulando) { return; } // Controle de FPS: if (deltaT < tempo) { // Colocar o Thread em “sleep mode”, pois falta // algum tempo para iniciar [NSThread sleepForTimeInterval:(tempo - deltaT)]; //NSLog(@” deltaT %f sleep: %f”, deltaT, (tempo - deltaT)); }
[self displayMem:5]; world->Step(deltaT,
Capítulo 9 - Vamos Criar um����������� Game������ — 311
cenaCorrente.box2d.velocityInterations, cenaCorrente.box2d.positionInterations);
if (!acabou) { segundosDeJogo += deltaT; } if (segundosDeJogo >= cenaCorrente.limitesegundos)
{ // O jogo acabou tempoAviso = 3; [self resetBola]; chutou = YES; [self trocarAviso:15 forcar:YES]; acabou = YES; } if (aviso != nil) { tempoAviso -= deltaT; if (tempoAviso <= 0) { [self trocarAviso: 0 forcar: YES]; } } [self tratarColisaoNPC];
}
if (resetarBola) { [self resetBola]; }
O método no iOS parece maior, mas não é. No Android, os métodos são divididos em duas classes: “OpenGLAvancadoRenderer.java” e “Renderer. java”, que é derivada da primeira.
Colisões Eu poderia identificar as colisões entre a bola (Player Object) e as garrafas (NPCs) diretamente dentro de um “ContactListener” do Box2D. Porém, eu não preciso saber quando a bola atingiu uma garrafa, mas apenas se a garrafa caiu, o que faz com que sua altura seja menor que a altura original. O motivo é que, ao ser atingida, a garrafa pode apenas “balançar”, sem cair, logo, o que eu preciso saber é se a garrafa caiu e, não, se foi atingida. Em um game do tipo “Shooter” seria diferente.
312 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Mas eu preciso de um teste de colisão, pois quero saber se a bola atingiu o chão. Sempre que a bola cai no chão, é reposicionada no lado esquerdo, no ponto de início. É como se alguém pegasse a bola e jogasse de volta. Então, usei um “ContactListener” para identificar isso. Android No Java, estou usando JBox2D, então só preciso criar uma classe Java que implemente a interface “ContactListener”. Eu criei uma “inner class” dentro da classe “Renderer.java”: class Contato implements ContactListener { @Override public void beginContact(org.jbox2d.dynamics. contacts.Contact c) { int id1 = ((Integer)c.getFixtureA().getBody().getUserData()). intValue(); int id2 = ((Integer)c.getFixtureB().getBody().getUserData()). intValue(); // A bola bateu no chão if ((id1 == bola.getId() && id2 == 2) || (id1 == 2 && id2 == bola.getId())) { bolaChao(); } } @Override public void Contact arg0) { }
endContact(org.jbox2d.dynamics.contacts.
@Override public void postSolve(org.jbox2d.dynamics.contacts. Contact arg0,
Capítulo 9 - Vamos Criar um����������� Game������ — 313 }
ContactImpulse arg1) {
@Override public void preSolve(org.jbox2d.dynamics.contacts. Contact arg0, Manifold arg1) { } }
O método que me interessa é o “BeginContact”, que recebe uma instância de “org.jbox2d.dynamics.contacts.Contact”. Através desta instância, eu posso acessar as instâncias dos dois corpos que entraram em contato (“Body” no JBox2D e “b2Body” no Box2D C++). Neste momento, a única maneira de identifcar os corpos que colidiram é através do atributo “UserData”. Neste caso, quando eu carrego um GameObject, passo o seu “id” dentro do “UserData”, logo, posso testar se foi a Bola (id = 1) que colidiu com o chão (id = 2). Neste caso, eu invoco o método “bolaChao()”, da classe “Renderer.java”, que aciona o evento de reposicionar a bola. Para que nosso “ContactListener” funcione, temos que adicioná-lo ao nosso “mundo” Box2D. E é o que fazemos no final método “onSurfaceChanged()”: this.world.setContactListener(new Contato());
iOS No iOS é um pouco mais complicado... O Box2D é feito em C++, logo, o “ContactListener” tem que ser uma classe C++ , que estenda a classe “b2ContactListener”. Então, eu criei uma classe separada no projeto iOS, formada pelos arquivos “Contato.h” e “Contato.mm” (“mm” é a extensão para código em C++ dentro do projeto). #import “Contato.h” #import “OGBPGameObject.h” void Contato::BeginContact(b2Contact* contact) { OGBPGameObject * id1 = (__bridge OGBPGameObject *) (contact->GetFixtureA()->GetBody()>GetUserData()); OGBPGameObject * id2 = (__bridge OGBPGameObject *)
314 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
(contact->GetFixtureB()->GetBody()->GetUserData());
// A bola bateu no chão
}
if ((id1.idGO == 1 && id2.idGO == 2) || (id1.idGO == 2 && id2.idGO == 1)) { [this->vc bolaChao]; }
void Contato::EndContact(b2Contact* contact) { } void Contato::PreSolve(b2Contact* b2Manifold* oldManifold) { }
contact,
const
void Contato::PostSolve(b2Contact* b2ContactImpulse* impulse) { }
contact,
const
Contato::Contato(OGCTViewController * mvc) this->vc = mvc; }
{
Contato::~Contato() { }
Primeiramente, eu resolvi colocar a instância de GameObject dentro do “UserData” do Box2D, pois isto facilitaria muito a identificação do GameObject dentro do “ContactListener”: body->SetUserData((__bridge void*)go);
Mas o que é esse tal de “__bridge”? É um “cast” que copia um ponteiro de objeto NSObject para um ponteiro comum C++, sem transferência de propriedade. Isto está relacionado com o ARC e a política de liberação de memória. Sempre que formos usar uma instância de qualquer NSObject com ponteiros comuns (“void*”, “int*” etc), temos que fazer este “cast”. Bem, no método “BeginContact” eu recebo dois ponteiros “void *” e os copio para ponteiros NSObject, de modo que eu possa usar suas propriedades sem problemas.
Capítulo 9 - Vamos Criar um����������� Game������ — 315
Tem mais uma novidade: tive que criar uma propriedade apontando para o View Controller, de modo a invocar o método “bolaChao”. Esta propriedade é passada no momento em que crio a instância do “ContactListener”: if (!mContato) { mContato = new Contato(self); } world->SetContactListener(mContato);
Registro de tempos Eu precisava de uma maneira de registrar os melhores tempos em cada nível, funcionalidade comum em games móveis. Porém, deixei de fora a parte de “Social Gaming”, que é compartilhar seus pontos com os amigos. É claro que eu poderia usar um banco de dados SQLite para armazenar a pontuação, porém, para simplificar as coisas, eu usei os mecanismos mais comuns para armazenamento. Android No Android, eu crio um arquivo texto, usando os métodos “openFileOutput()” e “openFileInput()”, da classe “android.content.Context”. Para isto, criei uma classe “Records.java” que lida com a gravação e leitura da pontuação: public static long[] getNiveis(Context context) { Records.context = context; if (Records.niveis == null) { Records.niveis = loadLevels(); } return Records.niveis; } private static long[] loadLevels() { long [] levels = new long [Records.MAXLEVELS]; Calendar cal = Calendar.getInstance(); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); for (int x=0; x
316 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS openFileInput(nome); DataInputStream dis = new DataInputStream(arquivo); for (int x=0; x < Records.MAXLEVELS; x++) { levels[x] = dis.readLong(); } dis.close(); arquivo.close(); } catch (FileNotFoundException fnf) { try { FileOutputStream saida = Records. context.openFileOutput (nome, Context.MODE_PRIVATE); DataOutputStream dos = new DataOutputStream(saida); for (int x=0; x < Records.MAXLEVELS; x++) { dos.writeLong(levels[x]); } dos.close(); saida.close(); } catch (FileNotFoundException e) { Log.e(“RECORDS”, “Error creating new recors: “ + e.getMessage()); } catch (IOException e) { Log.e(“RECORDS”, “Exception writing records: “ + e.getMessage()); } } catch (IOException e) { Log.e(“RECORDS”, “Exception reading records: “ + e.getMessage()); } return levels; } public static void updateLevels(long [] niveis) { try {
Capítulo 9 - Vamos Criar um����������� Game������ — 317 context.deleteFile(“records.txt”); FileOutputStream saida = Records.context. openFileOutput(“records.txt”, Context.MODE_PRIVATE); DataOutputStream dos = new DataOutputStream(saida); for (int x=0; x < Records.MAXLEVELS; x++) { dos.writeLong(niveis[x]); } dos.close(); saida.close(); } catch (FileNotFoundException e) { Log.e(“RECORDS”, “Error creating new recors: “ + e.getMessage()); } catch (IOException e) { Log.e(“RECORDS”, “Exception writing records: “ + e.getMessage()); } }
O método “loadLevels()” retorna um array contendo a pontuação em cada nível. E o método “updateLevels()” grava o arquivo com o conteúdo do array. Eu criei uma tela que mostra a pontuação atual (“Pontos.java”), que lê a pontuação e mostra para o jogador com o comando: long [] tempos = getApplicationContext());
Records.getNiveis(this.
Durante o game, se todos os NPCs forem derrubados, eu testo se o tempo de jogo foi menor que o registrado para aquele nível, e, neste caso, atualizo o arquivo: if (this.quantidadeNPC == 0) { // Acabou! this.tempoAviso = 3; this.resetBola();
chutou = true; trocarAviso(16, true); acabou = true; long [] niveis = Records.getNiveis(context); if (diferencaTempo < niveis[cenaCorrente.getNumero()
318 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS - 1]) { niveis[cenaCorrente.getNumero() diferencaTempo; } Records.updateLevels(niveis); }
-
1]
=
iOS Eu sei que já parece um bordão, mas: “No iOS é um pouco mais complicado...” Eu resolvi usar uma “Property List” para armazenar a pontuação. Para isto, criei um arquivo “records.plist”, dentro do grupo “Supporting files”: niveis 0.0 0.0 0.0
Este arquivo é apenas um modelo. O iOS não me deixa gravar nada dentro do “Application Bundle”, logo, eu copio este arquivo para o diretório gravável do dispositivo. Eu criei uma classe BYBLRecords que lê e armazena a pontuação dentro de uma “Property List”, localizada no diretório gravável da aplicação. Eis os métodos desta classe: @implementation BYBLRecords +(NSMutableArray *)getNiveis { if (niveis == nil) { [self loadLevels]; } return niveis; }
Capítulo 9 - Vamos Criar um����������� Game������ — 319
+(void)updateLevels:(NSMutableArray *)niveis { NSError *error; NSString *errordesc; NSFileManager* fileManager = [NSFileManager defaultManager]; NSArray *paths = NSSearchPathForDirectoriesInDomai ns(NSDocumentDirectory, NSUserDomainMask, YES); NSString *documentsDirectory = [paths objectAtIndex:0]; NSString *writableDBPath = [documentsDirectory str ingByAppendingPathComponent:@”records.plist”]; BOOL success = [fileManager fileExistsAtPath:writab leDBPath]; if (!success) { NSString *defaultDBPath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@”recor ds.plist”]; success = [fileManager copyItemAtPath:defaultDBPath toPath:writableDBPath error:&error]; } NSDictionary *plistDict = [NSDictionary dictionaryWithObjects: [NSArray arrayWithObjects: niveis, nil] forKeys:[NSArray arrayWithObjects: @”niveis”, nil]]; NSData *plistData = [NSPropertyListSerialization dataFromPropertyList:plistDict format:NSPropertyListXMLFormat_v1_0 errorDescription:&errordesc]; if(plistData) { writableDBPath = [documentsDirectory stringBy AppendingPathComponent:@”records.plist”]; [plistData writeToFile:writableDBPath atomically:YES]; }
320 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS else { NSLog(errordesc); } } +(void) loadLevels { niveis = [[NSMutableArray alloc] initWithCapacity:MAXLEVELS]; for (int x=0; x
*documentsDirectory
=
[paths
NSString *writableDBPath = [documentsDirectory str ingByAppendingPathComponent:@”records.plist”]; BOOL success = [fileManager fileExistsAtPath:writab leDBPath]; if (!success) { [self updateLevels:niveis]; NSString *writableDBPath = [documentsDirectory stringByAppendingPathComponent:@”records.plist”]; } NSData *plistXML = [[NSFileManager defaultManager] contentsAtPath:writableDBPath]; NSDictionary *temp = (NSDictionary *) [NSPropertyListSerialization propertyListFromData:plistXML mutabilityOption:NSPropertyListMutableContain ersAndLeaves format:&format errorDescription:&errorDesc];
Capítulo 9 - Vamos Criar um����������� Game������ — 321 if (!temp) { NSLog(@”Error reading plist: %@, format: %d”, errorDesc, format); } niveis = [NSMutableArray arrayWithArray:[temp objectForKey:@”niveis”]]; } @end
Nos dois principais métodos, eu testo se existe o arquivo “records.plist” no diretório gravável da aplicação. Eu não pretendo explicar como funciona o sistema de arquivos do iOS, mas recomendo que você leia o documento “File System Programming Guide”, da documentação do iOS:
https://developer.apple.com/library/ios/#documentation/FileManagement/ Conceptual/FileSystemProgrammingGUide/Introduction/Introduction.html
Para apresentar os pontos ao jogador, eu criei uma “view”, que ao ser invocada, lê a “Property List” e mostra para o usuário (classe “BYBLTextoViewController”, método “viewDidLoad”): - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. CGRect rect = [[UIApplication sharedApplication] statusBarFrame]; [[UIApplication sharedApplication] setStatusBarHidden:YES withAnimation:UIStatusBarAnima tionFade]; UIImage *img = [UIImage imageNamed:@”splash.png”]; CGSize landSize = CGSizeMake(self.view.frame.size. height, self.view.frame.size.width + rect.size.width); UIColor *background = [[UIColor alloc] initWithPatternImage: [self resizeImage:img scaledToSize:landSize] ]; self.view.backgroundColor = background; if (self.isAjuda) { NSBundle * bundle = [NSBundle bundleForClass:self];
322 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS [texto setText:[bundle localizedStringForKey: @”helptext” value:@”404” table:nil]]; } else { [self textoPontos]; } } - (void)textoPontos { NSBundle * bundle = [NSBundle bundleForClass:self]; NSString * cab = [bundle localizedStringForKey:@” headerpoints” value:@”X” table:nil]; NSString * levelhdr = [bundle localizedStringForKey:@”level” value:@”X” table:nil]; NSMutableString * saida = [[NSMutableString alloc] initWithCapacity:200]; [saida appendString:cab]; NSDateFormatter *df = [[NSDateFormatter alloc] init]; [df setDateFormat:@”mm:ss”]; NSMutableArray *pontos = [BYBLRecords getNiveis]; int x = 1; for (NSNumber * ponto in pontos) { NSTimeInterval wtempo = [ponto doubleValue]; [saida appendString: [NSString stringWithFormat:@”%@ %d: %@\r\n”,levelhdr,(x++),[df stringFromDate:[NSDate dat eWithTimeIntervalSince1970:wtempo]] ]];
}
} [texto setText: saida];
E, quando todos os NPCs caíram, eu testo se o tempo foi menor que o registrado para o nível. Isto é feito no método “tratarColisaoNPC”, da classe “OGCTViewController”: if (quantidadeNPC == 0) { // Acabou! tempoAviso = 3;
Capítulo 9 - Vamos Criar um����������� Game������ — 323 [self resetBola]; chutou = YES; [self trocarAviso:16 forcar:YES]; acabou = YES; NSMutableArray * niveis = [BYBLRecords getNiveis]; NSTimeInterval tinterval = [(NSNumber *) ([niveis objectAtIndex:(cenaCorrente.numero - 1)]) doubleValue]; if (segundosDeJogo < tinterval || tinterval == 0) { [niveis setObject: [NSNumber numberWithDouble:segundosDeJ ogo] atIndexedSubscript:(cenaCorrente.numero - 1)]; } }
[BYBLRecords updateLevels: niveis];
Gestão de memória no iOS Fazer o game no Android é simples: codificar, testar e pronto! Como estou usando apenas Java (incluindo o JBox2D), o Garbage Collector se encarrega de “limpar a área”, após o término de cada nível. Tanto é que eu consigo jogar o Game à vontade, mesmo em dispositivos pequenos, como o meu LG P500, com 170 MB de memória RAM. Assim que terminei de testar o game no simulador do iOS, instalei no meu iPad e fui jogar, todo feliz. Mal consegui jogar dois níveis, e a tela apagou toda, sem explicação. Então, conectei ao Mac e rodei usando o Xcode e descobri o problema: memória! Quando o iOS está com quantidade de memória baixa, ele informa sua aplicação através do método “didReceiveMemoryWarning”, e você tem que liberar memória rapidamente. Por que isso acontece? No iOS nós não terminamos as aplicações. Elas ficam sempre na memória, até que seja necessário liberar memória. Neste game, cada vez que o usuário escolhe um nível, outra instância do View Controller é criada e todas as informações são carregadas novamente.
324 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Como temos muitas texturas, em certos momentos o sistema fica com baixa memória mesmo. Como resolver este problema? Existem alguns passos que você deve seguir, de modo a se certificar que está utilizando a memória e liberando quando não é mais necessária.
Uso do GLKBaseEffect Neste game, nós carregamos o GLKViewController a cada novo nível jogado, entrando no modo OpenGL. Então, a liberação de memória se torna crítica. Há vários posts na Internet, especialmente no “Stack Overflow” (http:// stackoverflow.com/), argumentando que o GLKBaseEffect tem algum tipo de “memory leak”, ou seja, não está liberando memória corretamente. Eu não detectei um “Memory Leak” específico do GLKBaseEffect. Se você liberar as texturas e os buffers, provavelmente não terá problemas (veremos isto mais adiante). Eu preferi utilizar o OpenGL ES diretamente, pois quero ter um controle maior do processo de renderização. No exemplo “\Codigo\OpenGLiOS\OpenGLBasico1.zip”, eu uso apenas as funções do OpenGL ES, criando e compilando Shaders, logo, você pode usar este código como base. Você terá que criar e compilar os Shaders, da mesma maneira que fazemos na versão Android. E, além disto, terá que alterar a maneira como carrega texturas (eu suspeitei também do GLKTextureLoader). Carregando texturas sem o GLKTextureLoader Modifiquei significativamente o método “carregarCoordenadasTextura”, da classe “OGCTViewController”, que eu uso para carregar as imagens e passar para a GPU: - (void)carregarCoordenadasTextura: props imagem: (NSString*) nome {
(OGBPGLProps
*)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glEnable(GL_BLEND); CGImageRef spriteImage = CGImageRetain([UIImage imageNamed:nome].CGImage);
Capítulo 9 - Vamos Criar um����������� Game������ — 325 if (!spriteImage) { NSLog(@”Failed to load image %@”, nome); exit(1); } size_t width = CGImageGetWidth(spriteImage); size_t height = CGImageGetHeight(spriteImage); GLubyte * spriteData = (GLubyte *) calloc(width*height*4, sizeof(GLubyte)); CGContextRef spriteContext = CGBitmapContextCreate( spriteData, width, height, 8, width*4,CGImageGetColorS pace(spriteImage), kCGImageAlphaPremultipliedLast); CGContextDrawImage(spriteContext, CGRectMake(0, 0, width, height), spriteImage); GLuint texName; glGenTextures(1, &texName); glBindTexture(GL_TEXTURE_2D, texName); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_ FILTER, GL_LINEAR_MIPMAP_NEAREST); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, spriteData); glGenerateMipmap(GL_TEXTURE_2D);
326 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS free(spriteData); CGImageRelease(spriteImage); CGContextRelease(spriteContext); spriteImage = nil; props.textureName = texName; }
Eu carrego a imagem para uma referência (CGImageRef) e crio um contexto Quartz, que desenha a imagem em uma área que eu criei (spriteData). Depois, eu transfiro a imagem para a GPU, com a função “glTextImage2D” e libero o contexto, a imagem e o buffer que aloquei. Assim, eu garanto que não haverá outras áreas de memória com a imagem, além da utilizada pela GPU. Renderizando sem o GLKBaseEffect Nós temos que usar o programa que criamos com os nossos Shaders e passar os argumentos necessários, incluindo a matriz MVP (Model-View-Projection): glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glUseProgram(_program); if (cenaCorrente.texturaFundo != nil) { glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, cenaCorrente.glProps. textureName); glBindBuffer(GL_ARRAY_BUFFER, cenaCorrente.glProps. vobVertices); glVertexAttribPointer(maPositionHandle, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 3, BUFFER_OFFSET(0)); glEnableVertexAttribArray(maPositionHandle); glBindBuffer(GL_ARRAY_BUFFER, _textureBuffer); glVertexAttribPointer(maTextureHandle, 2, GL_ FLOAT, GL_FALSE, sizeof(GLfloat) * 2, BUFFER_OFFSET(0)); glEnableVertexAttribArray(maTextureHandle); GLKMatrix4 matrizModelo = GLKMatrix4Identity; glUniformMatrix4fv(uniforms[UNIFORM_ MODELVIEWPROJECTION_MATRIX], 1, 0, matrizModelo.m); glUniform1f(uniforms[UNIFORM_FADE_FACTOR], 1.0f);
Capítulo 9 - Vamos Criar um����������� Game������ — 327
}
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindTexture(GL_TEXTURE_2D, 0);
Para começar, temos que usar a função “glBindTexture” para indicar qual textura vamos usar, depois, temos que passar os identificadores dos atributos de posição e textura que criamos nos Shaders (maPositionHandle e maTextureHandle), ao invés do padrão que o GLKBaseEffect utiliza (GLKVertexAttribPosition e GLKVertexAttribTexCoord0). Depois, temos que passar a matriz multiplicada Modelo-Câmera-Projeção como um “uniform” para o nosso Shader. E, como estou usando o mesmo Shader do exemplo de Sistema de Partículas, mantive o “uniform” que indica o percentual de transparência.
Procure Memory Leaks O Xcode vem com uma ferramenta muito boa: “Instruments”, que permite executar o programa e observar o que está acontecendo. Para procurar por “Memory Leaks”, execute seu aplicativo com o comando “Profile” (menu “Product / Profile”). Ele vai recompilar seu programa e executar capturando estatísticas. Ao iniciar a instrumentação, você pode escolher o que quer detetar, neste caso, queremos saber se existem “Leaks”. Ele vai mostrar dois instrumentos: “Allocations” e “Leaks”, conforme a figura a seguir.
Ilustração 98: Análise de memória
328 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Os picos que vemos no gráfico mostram os momentos em que entrei em algum nível de jogo, e as baixas mostram quando saí. O gráfico tem que ficar neste formado, de vários “submarinos” lado a lado. Temos que garantir que a parte baixa permaneça constante, sem aumento de memória alocada. Uma boa visão é a “heapshots”, que mostra o crescimento da memória em cada momento. Se houver “Memory Leak” de instâncias de NSObjects, que estão sujeitas ao ARC, isto aparecerá no instrumento “Leaks”. Porém, não é o caso. O problema é o crescimento da alocação de memória de um ciclo para outro. Ou seja, quando eu entro e saio de um nível, não pode haver aumento de memória alocada. Mas é o que está acontecendo. A visão “heapshots”, do instrumento “Allocations”, permite que eu marque determinados instantes no tempo para posterior comparação. Rode seu programa e vá realizando operações. Quando julgar necessário, clique no botão “Mark heap” para marcar uma posição. Depois, é só parar a execução (botão “Stop”) e analisar os “heapshots”. Note que a coluna “Heap Growth” mostra o crescimento de memória alocada a cada momento. Não deveria haver crescimento nos heapshots 2, 3 e 5, quando eu saí de um nível. Isto demonstra que há memória ainda não liberada, o que pode forçar o iOS a cancelar aplicações.
Verifique se está realmente liberando a memória A primeira coisa que você deve fazer é implementar o método “dealloc” no View Controller. Neste método, você tem que se certificar que todas as referências estão liberadas. Como utilizamos ARC, não usamos mais o método “release”. Mas isto não significa que o ARC vai liberar imediatamente todas as referências. Pode haver referências cruzadas que impeçam a liberação de memória pelo ARC. Uma boa prática é a seguinte: faça com que todas as propriedades que referenciem objetos sejam “nil”. Eu criei um método “terminar”, que é chamado quando um nível termina ou quando o método “dealloc”, do View Controller é invocado: - (void)terminar { [self tearDownGL];
Capítulo 9 - Vamos Criar um����������� Game������ — 329 if ([EAGLContext currentContext] == self.context) { /* ATENÇÃO: é muito importante tornar todas as referências ao contexto OpenGL = nil. Assim, o ARC poderá liberar a memória que foi utilizada por ele. Caso contrário, as texturas não serão liberadas e haverá um Memory Leak que não aparece na instrumentação, como tal. */ self.context = nil; view = (GLKView *)self.view; view.context = nil; [EAGLContext setCurrentContext:nil];
}
} [self deleteBox2D]; self->mapaCaracteres = nil; self->gameModel = nil; self->cenaCorrente = nil; self->modeloTela = nil; panRecognizer = nil; tapRecognizer = nil; telaTopLeft = nil; telaBottomRight = nil; view = nil; bolaBody = nil; bola = nil; nomeGame = nil; nomeTela = nil; inicio = nil; qtdOrdem = nil; aviso = nil; world = NULL;
O mais importante é liberar adequadamente o contexto OpenGL. Como eu tenho referências a ele na classe View Controller e na própria view, tenho que liberar esta memória antes de mais nada. Depois, eu nulo todas as propriedades que representem ponteiros para instâncias de NSObjects. No método “tearDownGL” eu libero tudo o que aloquei:
330 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS - (void)tearDownGL { [self deleteGameModel:gameModel]; [EAGLContext setCurrentContext:self.context]; self.effect = nil; } - (void) deleteGameModel: (OGBPGameModel * ) model { for (OGBPCena * cena in model.cenas) { if (cena.glProps != nil) { [self deleteGLprops:cena.glProps]; cena.glProps = nil; } for (OGBPGameObject * go in cena.objetos) { if (go.glProps != nil) { [self deleteGLprops:go.glProps]; GLuint buffer = go.glProps.vobVertices; glDeleteBuffers(1, &buffer); if (go.b2dBody != nil) { world->DestroyBody(go.b2dBody); go.b2dBody = nil; } } } } glDeleteBuffers(1, &(_textureBuffer)); cenaCorrente = nil; for (OGMTTextura * tex in modeloTela.texturas) { GLuint texture; texture = tex.idTextura; glDeleteTextures(1, &texture); [self checkError:@”DEL TXT”]; } for (OGMTPosicaoTela * pos in modeloTela.posicoes) { GLuint buffer = pos.hVobVertices; glDeleteBuffers(1, &buffer); [self checkError:@”DEL VBO POS”]; }
Capítulo 9 - Vamos Criar um����������� Game������ — 331
}
modeloTela = nil;
- (void) deleteGLprops: (OGBPGLProps * ) props { if (props.textureName != 0) { GLuint name = props.textureName; glDeleteTextures(1, &name); } if (props.vobVertices != 0) { GLuint vVert = props.vobVertices; glDeleteBuffers(1, &vVert); } }
Eu vou liberando a memória dos VBOs de vértices e textura, além das próprias texturas, de cada objeto do game, incluindo o cenário de fundo. Depois, também destruo os objetos Box2D que criei. Finalmente, eu invoco o método “deleteBox2D”, que libera a parte final dos objetos Box2D: - (void) deleteBox2D { if (mContato != nil && mContato->vc != nil) { mContato->vc = nil; delete mContato; mContato = nil; }
}
if (world != NULL) { delete world; }
Lembra-se que eu havia criado um ponteiro para o ViewController dentro do “ContactList”? Eu preciso liberá-lo também. E depois, preciso deletar o próprio “ContactList”, que é um objeto C++. A função “delete world” faz o resto, liberando qualquer memória ainda retida pelo Box2D.
332 —������������������������������������������������������ ������������������������������������������������������� Manual do Indie Game Developer - Versão Android e iOS
Conclusão O game ficou pronto e rodou bem em ambas as plataformas. Conseguimos criar um game para Android e iOS utilizando Box2D e OpenGL ES. Para os que não acreditam, vou provar.
Ilustração 99: O game rodando em um dispositivo Android
Ilustração 100: O mesmo game em um dispositivo iOS
Capítulo 9 - Vamos Criar um����������� Game������ — 333
Possíveis melhorias no game Eu criei um protótipo funcional de game. Ele serve para explicar os conceitos e também para que você experimente tudo o que mostrei no livro. É possível melhorar muito este game e eu começaria criando mais níveis, com diferenças de dificuldade menos gritantes. Com apenas três níveis, não terá sucesso. Eu diria que 20 níveis, para começar, está bom. Outra boa possibilidade de melhoria é trabalhar totalmente em OpenGL ES, evitando carga de views convencionais. Embora seja mais difícil, pois não existe renderização de textos, você pode evitar diversos problemas, como: lentidão e memory leaks (iOS). Finalmente, inclua algum tipo de progressão obrigatória, de modo a evitar que o jogador “zere” os níveis rapidamente. Eu sugeriria: 1. Não libere todos os níveis. Só libere um nível após o jogador zerar o anterior; 2. Crie grupos de níveis e estabeleça limites de desempenho para liberá-los. Por exemplo, para jogar os níveis de 4 a 6, é necessário fazer uma média menor ou igual a seis segundos nos níveis de 1 a 3; 3. Os níveis de um mesmo grupo devem ter dificuldades parecidas. Só acrescente desafios ao mudar o nível. Em breve, eu publicarei uma versão “profissional” deste game contemplando todas estas melhorias.
Impressão e Acabamento *Ui¿FD(GLWRUD&LrQFLD0RGHUQD/WGD 7HO