Estudo de caso: Inside World Wide Maze

World Wide Maze é um jogo em que você usa seu smartphone para guiar uma bola por labirintos 3D criados a partir de sites para tentar alcançar os pontos de meta.

World Wide Maze

O jogo usa muitos recursos de HTML5. Por exemplo, o evento DeviceOrientation recupera dados de inclinação do smartphone, que são enviados ao PC por WebSocket, onde os jogadores encontram o caminho em espaços 3D criados pelo WebGL e Web Workers.

Neste artigo, vou explicar exatamente como esses recursos são usados, o processo de desenvolvimento geral e os principais pontos de otimização.

DeviceOrientation

O evento DeviceOrientation (exemplo) é usado para extrair dados de inclinação do smartphone. Quando addEventListener é usado com o evento DeviceOrientation, um callback com o objeto DeviceOrientationEvent é invocado como um argumento em intervalos regulares. Os intervalos variam de acordo com o dispositivo usado. Por exemplo, no iOS + Chrome e no iOS + Safari, o callback é invocado a cada 1/20 de segundo, enquanto no Android 4 + Chrome, ele é invocado a cada 1/10 de segundo.

window.addEventListener('deviceorientation', function (e) {
  // do something here..
});

O objeto DeviceOrientationEvent contém dados de inclinação para cada um dos eixos X, Y e Z em graus (não em radianos) (saiba mais no HTML5Rocks). No entanto, os valores de retorno também variam de acordo com a combinação de dispositivo e navegador usados. Os intervalos dos valores reais de retorno estão dispostos na tabela abaixo:

Orientação do dispositivo.

Os valores na parte de cima, destacados em azul, são os definidos nas especificações do W3C. Os destacados em verde correspondem a essas especificações, enquanto os destacados em vermelho estão fora do padrão. Surpreendentemente, apenas a combinação Android-Firefox retornou valores que correspondiam às especificações. No entanto, quando se trata de implementação, faz mais sentido acomodar valores que ocorrem com frequência. Portanto, o World Wide Maze usa os valores de retorno do iOS como padrão e se ajusta para dispositivos Android.

if android and event.gamma > 180 then event.gamma -= 360

No entanto, isso ainda não é compatível com o Nexus 10. Embora o Nexus 10 retorne o mesmo intervalo de valores que outros dispositivos Android, há um bug que inverte os valores beta e gama. Isso está sendo resolvido separadamente. Talvez seja a orientação paisagem padrão?

Como isso demonstra, mesmo que as APIs que envolvem dispositivos físicos tenham especificações definidas, não há garantia de que os valores retornados vão corresponder a essas especificações. Por isso, é fundamental testar em todos os dispositivos em potencial. Isso também significa que valores inesperados podem ser inseridos, o que exige a criação de soluções alternativas. O World Wide Maze solicita que os jogadores iniciantes calibrem os dispositivos como a etapa 1 do tutorial, mas não vai calibrar corretamente para a posição zero se receber valores de inclinação inesperados. Portanto, ele tem um limite de tempo interno e solicita que o jogador mude para os controles do teclado se não for possível calibrar dentro desse limite.

WebSocket

No World Wide Maze, seu smartphone e PC estão conectados por WebSocket. Mais precisamente, eles estão conectados por um servidor de retransmissão entre eles, ou seja, smartphone para servidor para PC. Isso ocorre porque o WebSocket não tem a capacidade de conectar navegadores diretamente. O uso de canais de dados do WebRTC permite a conectividade ponto a ponto e elimina a necessidade de um servidor de retransmissão. No entanto, no momento da implementação, esse método só podia ser usado com o Chrome Canary e o Firefox Nightly.

Escolhi implementar usando uma biblioteca chamada Socket.IO (v0.9.11), que inclui recursos para reconexão em caso de um tempo limite de conexão ou desconexão. Usei o Socket.IO com o NodeJS, já que essa combinação mostrou o melhor desempenho do lado do servidor em vários testes de implementação do WebSocket.

Pareamento por números

  1. O PC se conecta ao servidor.
  2. O servidor atribui ao PC um número gerado aleatoriamente e lembra a combinação de número e PC.
  3. No seu dispositivo móvel, especifique um número e se conecte ao servidor.
  4. Se o número especificado for o mesmo de um PC conectado, seu dispositivo móvel será pareado com esse PC.
  5. Se não houver um PC designado, um erro vai ocorrer.
  6. Quando os dados chegam do seu dispositivo móvel, eles são enviados para o PC com que ele está pareado e vice-versa.

Também é possível fazer a conexão inicial pelo seu dispositivo móvel. Nesse caso, os dispositivos são simplesmente invertidos.

Sincronização de guias

O recurso de sincronização de guias específico do Chrome facilita ainda mais o processo de pareamento. Com ele, as páginas abertas no PC podem ser abertas em um dispositivo móvel com facilidade (e vice-versa). O PC pega o número de conexão emitido pelo servidor e o anexa ao URL de uma página usando history.replaceState.

history.replaceState(null, null, '/maze/' + connectionNumber)

Se a sincronização de guias estiver ativada, o URL será sincronizado após alguns segundos, e a mesma página poderá ser aberta no dispositivo móvel. O dispositivo móvel verifica o URL da página aberta e, se um número for anexado, a conexão será iniciada imediatamente. Isso elimina a necessidade de digitar números manualmente ou ler QR codes com uma câmera.

Latência

Como o servidor de retransmissão está localizado nos EUA, o acesso dele no Japão resulta em um atraso de aproximadamente 200 ms antes que os dados de inclinação do smartphone cheguem ao PC. Os tempos de resposta foram claramente lentos em comparação com os do ambiente local usado durante o desenvolvimento, mas a inserção de algo como um filtro passa-baixa (usei EMA) melhorou isso para níveis discretos. Na prática, um filtro passa-baixa também era necessário para fins de apresentação. Os valores de retorno do sensor de inclinação incluíam uma quantidade considerável de ruído, e a aplicação desses valores à tela resultava em muita vibração. Isso não funcionou com os saltos, que eram claramente lentos, mas nada poderia ser feito para resolver isso.

Como eu esperava problemas de latência desde o início, considerei configurar servidores de retransmissão em todo o mundo para que os clientes pudessem se conectar ao mais próximo disponível, minimizando a latência. No entanto, acabei usando o Google Compute Engine (GCE), que existia apenas nos EUA na época, então isso não foi possível.

O problema do algoritmo de Nagle

O algoritmo de Nagle geralmente é incorporado a sistemas operacionais para comunicação eficiente por buffer no nível TCP, mas descobri que não conseguia enviar dados em tempo real enquanto esse algoritmo estava ativado. Especificamente, quando combinado com o confirmação atrasada do TCP. Mesmo sem atrasos na ACK, o mesmo problema ocorre se a ACK estiver atrasada em um determinado grau devido a fatores como o servidor estar localizado no exterior.

O problema de latência de Nagle não ocorreu com o WebSocket no Chrome para Android, que inclui a opção TCP_NODELAY para desativar o Nagle, mas ocorreu com o WebSocket do WebKit usado no Chrome para iOS, que não tem essa opção ativada. O Safari, que usa o mesmo WebKit, também tinha esse problema. O problema foi informado à Apple pelo Google e aparentemente foi resolvido na versão de desenvolvimento do WebKit.

Quando esse problema ocorre, os dados de inclinação enviados a cada 100 ms são combinados em blocos que só chegam ao PC a cada 500 ms. O jogo não pode funcionar nessas condições. Para evitar essa latência, o lado do servidor envia dados em intervalos curtos (a cada 50 ms ou mais). Acredito que receber ACK em intervalos curtos faz com que o algoritmo de Nagle pense que está tudo bem para enviar dados.

Algoritmo de Nagle 1

Os gráficos acima mostram os intervalos de dados reais recebidos. Ele indica os intervalos de tempo entre os pacotes. O verde representa intervalos de saída e o vermelho representa intervalos de entrada. O mínimo é 54 ms, o máximo é 158 ms e o meio é próximo de 100 ms. Aqui, usei um iPhone com um servidor de retransmissão localizado no Japão. A saída e a entrada são de cerca de 100 ms, e a operação é suave.

Algoritmo de Nagle 2

Por outro lado, este gráfico mostra os resultados do uso do servidor nos EUA. Enquanto os intervalos de saída verdes permanecem estáveis em 100 ms, os intervalos de entrada variam entre valores mínimos de 0 ms e máximos de 500 ms, indicando que o PC está recebendo dados em blocos.

ALT_TEXT_HERE

Por fim, este gráfico mostra os resultados de evitar a latência fazendo com que o servidor envie dados de marcador de posição. Embora não tenha um desempenho tão bom quanto o uso do servidor japonês, fica claro que os intervalos de entrada permanecem relativamente estáveis em cerca de 100 ms.

Um bug?

Embora o navegador padrão no Android 4 (ICS) tenha uma API WebSocket, ele não pode se conectar, resultando em um evento connect_failed do Socket.IO. Ele expira internamente, e o lado do servidor também não consegue verificar uma conexão. Não testei isso com o WebSocket sozinho, então pode ser um problema do Socket.IO.

Como escalonar servidores de redirecionamento

Como a função do servidor de retransmissão não é tão complicada, aumentar e aumentar o número de servidores não é difícil, desde que você garanta que o mesmo PC e o dispositivo móvel estejam sempre conectados ao mesmo servidor.

Física

O movimento da bola no jogo (rolar ladeira abaixo, colidir com o chão, colidir com paredes, coletar itens etc.) é feito com um simulador de física 3D. Usei o Ammo.js, uma versão do mecanismo de física Bullet (link em inglês) amplamente usado em JavaScript usando o Emscripten (link em inglês) e o Physijs (link em inglês) como um "Web Worker".

Web Workers

Os Web Workers são uma API para executar JavaScript em linhas de execução separadas. O JavaScript iniciado como um Web Worker é executado como uma linha de execução separada da que o chamou originalmente. Assim, tarefas pesadas podem ser realizadas enquanto a página continua responsiva. O Physijs usa Web Workers de maneira eficiente para ajudar o mecanismo de física 3D, que normalmente é intenso, a funcionar sem problemas. O World Wide Maze processa o mecanismo de física e a renderização de imagens do WebGL com taxas de frames completamente diferentes. Portanto, mesmo que a taxa de frames caia em uma máquina de baixa especificação devido à carga pesada de renderização do WebGL, o mecanismo de física vai manter mais ou menos 60 fps e não vai impedir os controles do jogo.

QPS

Esta imagem mostra as taxas de frames resultantes em um Lenovo G570. A caixa de cima mostra a taxa de frames do WebGL (renderização de imagens), e a de baixo mostra a taxa de frames do mecanismo de física. A GPU é um chip Intel HD Graphics 3000 integrado, então a taxa de frames de renderização de imagens não atingiu os 60 fps esperados. No entanto, como o mecanismo de física alcançou a taxa de frames esperada, a jogabilidade não é muito diferente da performance em uma máquina de alta especificação.

Como as linhas de execução com Web Workers ativos não têm objetos de console, os dados precisam ser enviados para a linha de execução principal usando postMessage para produzir logs de depuração. O uso de console4Worker cria o equivalente a um objeto de console no worker, facilitando bastante o processo de depuração.

Service workers

As versões recentes do Chrome permitem definir pontos de interrupção ao iniciar Web Workers, o que também é útil para depuração. É possível encontrar essa informação no painel "Workers" nas Ferramentas para desenvolvedores.

Desempenho

Os estágios com contagem de polígonos alta às vezes excedem 100.000 polígonos,mas o desempenho não foi prejudicado, mesmo quando eles foram gerados inteiramente como Physijs.ConcaveMesh (btBvhTriangleMeshShape em Bullet).

Inicialmente, a taxa de frames caía à medida que o número de objetos que exigiam a detecção de colisão aumentava, mas a eliminação de processamento desnecessário no Physijs melhorou o desempenho. Essa melhoria foi feita em um fork do Physijs original.

Objetos fantasmas

Os objetos que têm detecção de colisão, mas nenhum impacto sobre a colisão e, portanto, nenhum efeito sobre outros objetos são chamados de "objetos fantasmas" no Bullet. Embora o Physijs não ofereça suporte oficial a objetos fantasmas, é possível criá-los com flags depois de gerar um Physijs.Mesh. O World Wide Maze usa objetos fantasmas para a detecção de colisão de itens e pontos de meta.

hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)

Para collision_flags, 1 é CF_STATIC_OBJECT e 4 é CF_NO_CONTACT_RESPONSE. Tente pesquisar no fórum do Bullet, no Stack Overflow ou na documentação do Bullet para saber mais. Como o Physijs é um wrapper para Ammo.js e Ammo.js é basicamente idêntico ao Bullet, a maioria das coisas que podem ser feitas no Bullet também pode ser feita no Physijs.

O problema do Firefox 18

A atualização do Firefox da versão 17 para a 18 mudou a maneira como os Web Workers trocam dados, e o Physijs parou de funcionar como resultado. O problema foi relatado no GitHub e resolvido após alguns dias. Embora essa eficiência de código aberto tenha me impressionado, o incidente também me lembrou que o World Wide Maze é composto por várias estruturas de código aberto diferentes. Estou escrevendo este artigo com a esperança de fornecer algum tipo de feedback.

asm.js

Embora isso não se refira diretamente ao World Wide Maze, o Ammo.js já oferece suporte ao asm.js anunciado recentemente pela Mozilla. Não é surpreendente, já que o asm.js foi criado basicamente para acelerar o JavaScript gerado pelo Emscripten, e o criador do Emscripten também é o criador do Ammo.js. Se o Chrome também oferecer suporte a asm.js, a carga de computação do mecanismo de física vai diminuir consideravelmente. A velocidade foi consideravelmente mais rápida quando testada com o Firefox Nightly. Talvez seja melhor escrever seções que exigem mais velocidade em C/C++ e depois transferi-las para JavaScript usando o Emscripten?

WebGL

Para a implementação do WebGL, usei a biblioteca mais desenvolvida, three.js (r53). Embora a revisão 57 já tenha sido lançada nos estágios finais de desenvolvimento, foram feitas mudanças importantes na API. Por isso, usei a revisão original para o lançamento.

Efeito de brilho

O efeito de brilho adicionado ao núcleo da bola e aos itens é implementado usando uma versão simples do chamado Método Kawase MGF. No entanto, enquanto o método Kawase faz com que todas as áreas brilhantes floresçam, o World Wide Maze cria alvos de renderização separados para áreas que precisam brilhar. Isso ocorre porque uma captura de tela do site precisa ser usada para texturas de palco. Extrair todas as áreas claras resultaria em todo o site brilhando se, por exemplo, ele tiver um plano de fundo branco. Também pensei em processar tudo em HDR, mas decidi não fazer isso desta vez, porque a implementação seria muito complicada.

Brilho

O canto superior esquerdo mostra a primeira passagem, em que as áreas de brilho foram renderizadas separadamente e depois um desfoque foi aplicado. O canto inferior direito mostra a segunda passagem, em que o tamanho da imagem foi reduzido em 50% e um desfoque foi aplicado. O canto superior direito mostra a terceira passagem, em que a imagem foi novamente reduzida em 50% e desfocada. As três foram sobrepostas para criar a imagem composta final mostrada no canto inferior esquerdo. Para o desfoque, usei VerticalBlurShader e HorizontalBlurShader, incluídos no three.js. Ainda há espaço para otimização.

Bola reflexiva

A reflexão na bola é baseada em um exemplo do three.js. Todas as direções são renderizadas a partir da posição da bola e usadas como mapas de ambiente. Os mapas de ambiente precisam ser atualizados toda vez que a bola se move, mas, como a atualização a 60 fps é intensa, eles são atualizados a cada três frames. O resultado não é tão suave quanto atualizar todos os frames, mas a diferença é praticamente imperceptível, a menos que seja apontada.

Shader, shader, shader…

A WebGL exige sombreadores (sombreadores de vértice, sombreadores de fragmentos) para toda a renderização. Embora os shaders incluídos no three.js já permitam uma ampla gama de efeitos, escrever o seu próprio é inevitável para sombreamento e otimização mais elaborados. Como o World Wide Maze mantém a CPU ocupada com o mecanismo de física, tentei usar a GPU escrevendo o máximo possível em linguagem de sombreamento (GLSL), mesmo quando o processamento da CPU (via JavaScript) seria mais fácil. Os efeitos de ondas do oceano dependem de shaders, naturalmente, assim como os fogos de artifício nos pontos de gol e o efeito de malha usado quando a bola aparece.

Bolas de sombreador

A imagem acima é de testes do efeito de malha usado quando a bola aparece. O da esquerda é o usado no jogo, composto por 320 polígonos. O mapa no centro usa cerca de 5.000 polígonos, e o da direita usa cerca de 300.000 polígonos. Mesmo com tantos polígonos, o processamento com shaders pode manter uma taxa de frames estável de 30 fps.

Malha de sombreador

Os pequenos itens espalhados pelo palco são integrados a uma malha, e o movimento individual depende de sombreadores que movem cada uma das pontas do polígono. Isso é de um teste para saber se o desempenho seria afetado com um grande número de objetos presentes. Cerca de 5.000 objetos são dispostos aqui, compostos por cerca de 20.000 polígonos. O desempenho não foi afetado.

poly2tri

As fases são formadas com base nas informações de contorno recebidas do servidor e depois poligonizadas pelo JavaScript. A triangulação, uma parte importante desse processo, é implementada de forma inadequada pelo three.js e geralmente falha. Portanto, decidi integrar uma biblioteca de triangulação diferente chamada poly2tri. O three.js já havia tentado fazer a mesma coisa no passado, então consegui fazer com que ele funcionasse simplesmente comentando parte dele. Como resultado, os erros diminuíram significativamente, permitindo que mais fases fossem jogáveis. O erro ocasional persiste e, por algum motivo, o poly2tri processa erros emitindo alertas. Por isso, ele foi modificado para gerar exceções.

poly2tri

A imagem acima mostra como o contorno azul é triangulado e os polígonos vermelhos são gerados.

Filtragem anisotrópica

Como o mapeamento MIP isotrópico padrão reduz as imagens nos eixos horizontal e vertical, a visualização de polígonos em ângulos oblíquos faz com que as texturas no final dos estágios do World Wide Maze pareçam texturas horizontalmente alongadas e de baixa resolução. A imagem no canto superior direito desta página da Wikipédia mostra um bom exemplo disso. Na prática, é necessária mais resolução horizontal, que o WebGL (OpenGL) resolve usando um método chamado filtragem anisotrópica. No three.js, definir um valor maior que 1 para THREE.Texture.anisotropy ativa a filtragem anisotrópica. No entanto, esse recurso é uma extensão e pode não ser compatível com todas as GPUs.

Otimizar

Como mencionado neste artigo sobre práticas recomendadas para WebGL, a maneira mais importante de melhorar o desempenho do WebGL (OpenGL) é minimizar as chamadas de renderização. Durante o desenvolvimento inicial do World Wide Maze, todas as ilhas, pontes e grades de proteção no jogo eram objetos separados. Isso às vezes resultava em mais de 2.000 chamadas de desenho, tornando as fases complexas difíceis de usar. No entanto, depois que eu agrupei os mesmos tipos de objetos em uma malha, as chamadas de renderização caíram para cerca de 50, melhorando significativamente o desempenho.

Usei o recurso de rastreamento do Chrome para otimizar ainda mais. Os criadores de perfil incluídos nas Ferramentas para desenvolvedores do Chrome podem determinar os tempos de processamento do método geral até certo ponto, mas o rastreamento pode informar exatamente quanto tempo cada parte leva, até 1/1000 de segundo. Consulte este artigo para saber como usar o rastreamento.

Otimização

Os resultados acima são de rastros da criação de mapas de ambiente para a reflexão da bola. Inserir console.time e console.timeEnd em locais aparentemente relevantes no three.js gera um gráfico parecido com este. O tempo flui da esquerda para a direita, e cada camada é algo como uma pilha de chamadas. A aninhação de um console.time em um console.time permite outras medições. O gráfico de cima é antes da otimização, e o de baixo é depois. Como mostra o gráfico de cima, o updateMatrix (embora a palavra seja truncada) foi chamado para cada renderização de 0 a 5 durante a pré-otimização. Modifiquei para que ele seja chamado apenas uma vez, já que esse processo é necessário apenas quando os objetos mudam de posição ou orientação.

O processo de rastreamento em si consome recursos, naturalmente. Portanto, inserir console.time em excesso pode causar um desvio significativo da performance real, dificultando a identificação de áreas para otimização.

Ajustador de performance

Devido à natureza da Internet, o jogo provavelmente será jogado em sistemas com especificações muito variadas. Find Your Way to Oz, lançado no início de fevereiro, usa uma classe chamada IFLAutomaticPerformanceAdjust para reduzir os efeitos de acordo com as flutuações na taxa de frames, ajudando a garantir uma reprodução suave. O World Wide Maze é baseado na mesma classe IFLAutomaticPerformanceAdjust e reduz os efeitos na seguinte ordem para tornar a jogabilidade o mais suave possível:

  1. Se a taxa de frames cair abaixo de 45 fps, os mapas de ambiente vão parar de ser atualizados.
  2. Se ainda estiver abaixo de 40 QPS, a resolução de renderização será reduzida para 70% (50% da proporção da superfície).
  3. Se ainda cair abaixo de 40 fps, o FXAA (anti-aliasing) será eliminado.
  4. Se ainda cair abaixo de 30 fps, os efeitos de brilho serão eliminados.

Vazamento de memória

Eliminar objetos de forma organizada é um pouco trabalhoso com o three.js. No entanto, deixá-los sozinhos obviamente levaria a vazamentos de memória. Por isso, criei o método abaixo. @renderer se refere a THREE.WebGLRenderer. A última revisão do three.js usa um método de dealocação um pouco diferente, então isso provavelmente não vai funcionar como está.

destructObjects: (object) =>
  switch true
    when object instanceof THREE.Object3D
      @destructObjects(child) for child in object.children
      object.parent?.remove(object)
      object.deallocate()
      object.geometry?.deallocate()
      @renderer.deallocateObject(object)
      object.destruct?(this)

    when object instanceof THREE.Material
      object.deallocate()
      @renderer.deallocateMaterial(object)

    when object instanceof THREE.Texture
      object.deallocate()
      @renderer.deallocateTexture(object)

    when object instanceof THREE.EffectComposer
      @destructObjects(object.copyPass.material)
      object.passes.forEach (pass) =>
        @destructObjects(pass.material) if pass.material
        @renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
        @renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
        @renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2

HTML

Pessoalmente, acho que a melhor coisa do app WebGL é a capacidade de projetar o layout da página em HTML. Criar interfaces 2D, como pontuação ou exibições de texto no Flash ou no openFrameworks (OpenGL), é um pouco difícil. O Flash pelo menos tem um ambiente de desenvolvimento integrado, mas o openFrameworks é difícil se você não estiver acostumado com ele (usar algo como o Cocos2D pode facilitar). O HTML, por outro lado, permite o controle preciso de todos os aspectos do design de front-end com CSS, assim como na criação de sites. Embora efeitos complexos, como partículas se condensando em um logotipo, sejam impossíveis, alguns efeitos 3D dentro dos recursos de transformações CSS são possíveis. Os efeitos de texto "GOAL" e "TIME IS UP" do World Wide Maze são animados usando escala na transição CSS (implementada com o Transit). Obviamente, as gradações de plano de fundo usam o WebGL.

Cada página do jogo (título, RESULTADO, CLASSIFICAÇÃO etc.) tem seu próprio arquivo HTML. Depois que eles são carregados como modelos, $(document.body).append() é chamado com os valores adequados no momento certo. Um problema era que os eventos do mouse e do teclado não podiam ser definidos antes da adição. Portanto, a tentativa de el.click (e) -> console.log(e) antes da adição não funcionava.

Internacionalização (i18n)

Trabalhar em HTML também foi conveniente para criar a versão em inglês. Escolhi usar a i18next, uma biblioteca de i18n da Web, para minhas necessidades de internacionalização, que pude usar sem modificações.

A edição e a tradução do texto do jogo foram feitas na planilha do Documentos Google. Como o i18next exige arquivos JSON, exportei as planilhas para TSV e as converti com um conversor personalizado. Fiz muitas atualizações antes do lançamento, então automatizar o processo de exportação da planilha do Google Docs teria facilitado muito as coisas.

O recurso de tradução automática do Chrome também funciona normalmente, já que as páginas são criadas com HTML. No entanto, às vezes, ela não consegue detectar o idioma corretamente, confundindo-o com um totalmente diferente (por exemplo, vietnamita), então esse recurso está desativado no momento. Ela pode ser desativada usando metatags.

RequireJS

Escolhi o RequireJS como meu sistema de módulo JavaScript. As 10.000 linhas de código-fonte do jogo são divididas em cerca de 60 classes (= arquivos coffee) e compiladas em arquivos js individuais. O RequireJS carrega esses arquivos individualmente na ordem adequada com base na dependência.

define ->
  class Hoge
    hogeMethod: ->

A classe definida acima (hoge.coffee) pode ser usada da seguinte maneira:

define ['hoge'], (Hoge) ->
  class Moge
    constructor: ->
      @hoge = new Hoge()
      @hoge.hogeMethod()

Para funcionar, a hoge.js precisa ser carregada antes da moge.js. Como "hoge" é designado como o primeiro argumento de "define", a hoge.js é sempre carregada primeiro (chamada de volta assim que a carga da hoge.js é concluída). Esse mecanismo é chamado de AMD, e qualquer biblioteca de terceiros pode ser usada para o mesmo tipo de callback, desde que ofereça suporte a AMD. Mesmo aqueles que não têm (por exemplo, three.js) vão funcionar de forma semelhante, desde que as dependências sejam especificadas com antecedência.

Isso é semelhante à importação do AS3, então não deve parecer tão estranho. Se você tiver mais arquivos dependentes, esta é uma solução possível.

r.js

O RequireJS inclui um otimizador chamado r.js. Isso agrupa o js principal com todos os arquivos js dependentes em um só e, em seguida, o minimiza usando o UglifyJS (ou o Closure Compiler). Isso reduz o número de arquivos e a quantidade total de dados que o navegador precisa carregar. O tamanho total do arquivo JavaScript do World Wide Maze é de cerca de 2 MB e pode ser reduzido para cerca de 1 MB com a otimização do r.js. Se o jogo pudesse ser distribuído usando gzip, isso seria reduzido para 250 KB. O GAE tem um problema que não permite a transmissão de arquivos gzip de 1 MB ou mais. Por isso, o jogo é distribuído descompactado como 1 MB de texto simples.

Criador de fases

Os dados de estágio são gerados da seguinte forma, realizados inteiramente no servidor do GCE nos EUA:

  1. O URL do site que será convertido em uma etapa é enviado pelo WebSocket.
  2. O PhantomJS tira uma captura de tela, e as posições das tags div e img são recuperadas e exibidas no formato JSON.
  3. Com base na captura de tela da etapa 2 e nos dados de posicionamento dos elementos HTML, um programa C++ personalizado (OpenCV, Boost) exclui áreas desnecessárias, gera ilhas, conecta as ilhas com pontes, calcula o guarda-corpo e as posições dos itens, define o objetivo etc. Os resultados são gerados no formato JSON e retornados ao navegador.

PhantomJS

O PhantomJS é um navegador que não requer tela. Ele pode carregar páginas da Web sem abrir janelas. Por isso, pode ser usado em testes automatizados ou para capturar capturas de tela no lado do servidor. O mecanismo do navegador é o WebKit, o mesmo usado pelo Chrome e pelo Safari. Portanto, o layout e os resultados da execução do JavaScript são mais ou menos os mesmos dos navegadores padrão.

Com o PhantomJS, JavaScript ou CoffeeScript são usados para escrever os processos que você quer executar. É muito fácil fazer capturas de tela, como mostrado neste exemplo. Eu estava trabalhando em um servidor Linux (CentOS), então precisei instalar fontes para exibir o japonês (M+ FONTS). Mesmo assim, a renderização de fontes é processada de maneira diferente do Windows ou do Mac OS. Portanto, a mesma fonte pode ter uma aparência diferente em outras máquinas (a diferença é mínima).

A recuperação das posições das tags img e div é basicamente processada da mesma forma que nas páginas padrão. O jQuery também pode ser usado sem problemas.

stage_builder

Inicialmente, considerei usar uma abordagem mais baseada em DOM para gerar estágios (semelhante ao Firefox 3D Inspector) e tentei algo como uma análise de DOM no PhantomJS. No final, porém, decidi usar uma abordagem de processamento de imagens. Para isso, escrevi um programa C++ que usa o OpenCV e o Boost chamado "stage_builder". Ele faz o seguinte:

  1. Carrega a captura de tela e os arquivos JSON.
  2. Converte imagens e texto em "ilhas".
  3. Cria pontes para conectar as ilhas.
  4. Elimina pontes desnecessárias para criar um labirinto.
  5. Coloca itens grandes.
  6. Coloca itens pequenos.
  7. Coloca proteções.
  8. Gera dados de posicionamento no formato JSON.

Confira abaixo os detalhes de cada etapa.

Como carregar a captura de tela e os arquivos JSON

O cv::imread normal é usado para carregar capturas de tela. Testei várias bibliotecas para os arquivos JSON, mas a picojson parecia ser a mais fácil de usar.

Converter imagens e texto em "ilhas"

Build de estágio

A imagem acima é uma captura de tela da seção "News" do site aid-dcc.com (clique para ver o tamanho real). As imagens e os elementos de texto precisam ser convertidos em ilhas. Para isolar essas seções, precisamos excluir a cor de plano de fundo branca, ou seja, a cor mais prevalente na captura de tela. Confira como fica depois:

Build de estágio

As seções brancas são as ilhas em potencial.

O texto está muito fino e nítido, então vamos engrossá-lo com cv::dilate, cv::GaussianBlur e cv::threshold. O conteúdo da imagem também está ausente, então vamos preencher essas áreas com branco, com base na saída de dados da tag img do PhantomJS. A imagem resultante fica assim:

Build de estágio

Agora o texto forma grupos adequados, e cada imagem é uma ilha.

Criação de pontes para conectar as ilhas

Quando as ilhas estão prontas, elas são conectadas por pontes. Cada ilha procura ilhas adjacentes à esquerda, à direita, acima e abaixo, depois conecta uma ponte ao ponto mais próximo da ilha mais próxima, resultando em algo como isto:

Build de estágio

Eliminar pontes desnecessárias para criar um labirinto

Manter todas as pontes tornaria o estágio muito fácil de navegar, então algumas precisam ser eliminadas para criar um labirinto. Uma ilha (por exemplo, a que está no canto superior esquerdo) é escolhida como ponto de partida, e todas as pontes conectadas a ela, exceto uma (selecionada aleatoriamente), são excluídas. Em seguida, a mesma coisa é feita para a próxima ilha conectada pela ponte restante. Quando o caminho chega a um beco sem saída ou leva de volta a uma ilha visitada anteriormente, ele volta para um ponto que permite o acesso a uma nova ilha. O labirinto é concluído quando todas as ilhas são processadas dessa forma.

Build de estágio

Colocar itens grandes

Um ou mais itens grandes são colocados em cada ilha, dependendo das dimensões dela, escolhendo os pontos mais distantes das bordas. Embora não seja muito claro, esses pontos são mostrados em vermelho abaixo:

Build de estágio

De todos esses pontos possíveis, o que está no canto superior esquerdo é definido como ponto de partida (círculo vermelho), o que está no canto inferior direito é definido como meta (círculo verde) e um máximo de seis dos outros são escolhidos para a colocação de itens grandes (círculo roxo).

Colocar itens pequenos

Build de estágio

Números adequados de itens pequenos são colocados ao longo de linhas a distâncias definidas das bordas da ilha. A imagem acima (não do aid-dcc.com) mostra as linhas de posicionamento projetadas em cinza, deslocadas e colocadas em intervalos regulares das bordas da ilha. Os pontos vermelhos indicam onde os itens pequenos são colocados. Como esta imagem é de uma versão em desenvolvimento, os itens estão dispostos em linhas retas, mas a versão final os espalha de forma um pouco mais irregular para cada lado das linhas cinza.

Colocar proteções

Os guarda-corpos são colocados basicamente ao longo dos limites externos das ilhas, mas precisam ser cortados nas pontes para permitir o acesso. A biblioteca de geometria do Boost foi útil para isso, simplificando cálculos geométricos, como determinar onde os dados de limite da ilha se cruzam com as linhas em ambos os lados de uma ponte.

Build de estágio

As linhas verdes que delimitam as ilhas são os guarda-corpos. Pode ser difícil de ver nesta imagem, mas não há linhas verdes onde as pontes estão. Essa é a imagem final usada para depuração, em que todos os objetos que precisam ser gerados em JSON são incluídos. Os pontos azuis claros são itens pequenos, e os pontos cinza são pontos de reinicialização propostos. Quando a bola cai no oceano, o jogo é retomado do ponto de reinicialização mais próximo. Os pontos de reinicialização são organizados mais ou menos da mesma forma que os itens pequenos, em intervalos regulares a uma distância definida da borda da ilha.

Como gerar dados de posicionamento no formato JSON

Também usei o picojson para a saída. Ele grava os dados na saída padrão, que é recebida pelo autor da chamada (Node.js).

Criar um programa C++ em um Mac para ser executado no Linux

O jogo foi desenvolvido em um Mac e implantado no Linux, mas, como o OpenCV e o Boost existiam para os dois sistemas operacionais, o desenvolvimento em si não foi difícil depois que o ambiente de compilação foi estabelecido. Usei as ferramentas de linha de comando no Xcode para depurar o build no Mac e, em seguida, criei um arquivo de configuração usando o automake/autoconf para que o build pudesse ser compilado no Linux. Depois, basta usar "configure && make" no Linux para criar o arquivo executável. Encontrei alguns bugs específicos do Linux devido a diferenças na versão do compilador, mas consegui resolvê-los com relativa facilidade usando o gdb.

Conclusão

Um jogo como esse pode ser criado com o Flash ou o Unity, o que traria muitas vantagens. No entanto, essa versão não requer plug-ins, e os recursos de layout do HTML5 + CSS3 se mostraram extremamente poderosos. É importante ter as ferramentas certas para cada tarefa. Fiquei surpreso com a qualidade do jogo, que foi feito totalmente em HTML5. Embora ainda falte muito em muitas áreas, estou ansioso para ver como ele vai se desenvolver no futuro.