Estudo de caso: Inside World Wide Maze

O World Wide Maze é um jogo em que você usa seu smartphone para navegar por uma bola que rola por labirintos 3D criados a partir de sites para tentar atingir os pontos dos objetivos.

Labirinto World Wide

O jogo oferece uso abundante de recursos HTML5. Por exemplo, o evento DeviceOrientation recupera dados de inclinação do smartphone, que são enviados ao PC via WebSocket, onde os jogadores encontram seus caminhos por espaços 3D criados por WebGL e Web Workers.

Neste artigo, explicarei precisamente como esses recursos são usados, o processo geral de desenvolvimento e os principais pontos para otimização.

DeviceOrientation

O evento DeviceOrientation (exemplo) é usado para recuperar dados de inclinação do smartphone. Quando addEventListener é usado com o evento DeviceOrientation, um callback com o objeto DeviceOrientationEvent é invocado como argumento em intervalos regulares. Os intervalos variam de acordo com o dispositivo usado. Por exemplo, no iOS + Chrome e iOS + Safari, o callback é invocado a cada 1/20 de segundo, enquanto no Android 4 + Chrome, 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). Leia mais em HTML5Rocks (em inglês). No entanto, os valores de retorno também variam de acordo com a combinação do dispositivo e do navegador usados. Os intervalos dos valores de retorno reais estão disponíveis na tabela abaixo:

É a orientação do dispositivo.

Os valores na parte superior, destacados em azul, são aqueles definidos nas especificações do W3C. Aquelas destacadas em verde correspondem a essas especificações, enquanto as destacadas em vermelho se desviam. Surpreendentemente, somente 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 adequadamente.

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

No entanto, ele 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. Estamos cuidando disso separadamente. Talvez o padrão seja a orientação paisagem?

Como demonstrado, mesmo que as APIs envolvendo dispositivos físicos tenham especificações definidas, não há garantia de que os valores retornados correspondam a essas especificações. Por isso, é fundamental testá-los em todos os dispositivos em potencial. Isso também significa que valores inesperados podem ser inseridos, o que requer a criação de soluções alternativas. O World Wide Maze pede que os jogadores iniciantes calibrem seus dispositivos conforme a primeira etapa do tutorial, mas o sistema não fará a calibração para a posição zero corretamente se receber valores de inclinação inesperados. Portanto, ele tem um limite de tempo interno e solicita que o jogador troque para os controles do teclado se não for possível fazer a calibração 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 redirecionamento entre eles, ou seja, de smartphone para servidor e PC. Isso ocorre porque o WebSocket não tem a capacidade de conectar navegadores diretamente entre si. O uso de canais de dados WebRTC permite a conectividade ponto a ponto e elimina a necessidade de um servidor de redirecionamento. No entanto, no momento da implementação, esse método só pode ser usado com o Chrome Canary e o Firefox Nightly.

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

Pareamento por números

  1. Seu PC se conectará ao servidor.
  2. O servidor dá 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 conecte-se ao servidor.
  4. Se o número especificado for o mesmo de um PC conectado, isso significa que o dispositivo móvel está pareado com esse PC.
  5. Se não houver um PC designado, ocorrerá um erro.
  6. Quando chegarem dados do seu dispositivo móvel, eles serão enviados ao PC com o qual estão pareados e vice-versa.

Você também pode fazer a conexão inicial com 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 que estão abertas no PC podem ser abertas com facilidade em um dispositivo móvel (e vice-versa). O PC toma 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, ele começará a se conectar imediatamente. Isso elimina a necessidade de inserir números manualmente ou de ler QR codes com a câmera.

Latência

Como o servidor de redirecionamento está localizado nos EUA, acessá-lo do 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 eram claramente lentos em comparação com o ambiente local usado durante o desenvolvimento, mas a inserção de algo como um filtro de passagem baixa (usei a Associação dos fornecedores de entretenimento) melhorou isso para níveis discretos. (Na prática, um filtro de passagem de baixa frequência 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 causava muita agitação.) Isso não funcionou com saltos, que eram claramente lentos, mas nada poderia ser feito para resolver o problema.

Como eu esperava problemas de latência desde o início, pensei em configurar servidores de redirecionamento em todo o mundo para que os clientes pudessem se conectar à opção mais próxima disponível (o que minimizava a latência). No entanto, acabei usando o Google Compute Engine (GCE), que existia apenas nos EUA na época, e por isso isso não foi possível.

O problema do algoritmo de Nagle

Geralmente, o algoritmo Nagle é incorporado em sistemas operacionais para uma comunicação eficiente por meio do armazenamento em buffer no nível do TCP, mas descobri que não consigo enviar dados em tempo real enquanto esse algoritmo está ativado. Especificamente quando combinado com a confirmação atrasada do TCP. Mesmo sem ACK atrasado, o mesmo problema ocorre se ACK sofrer atrasos devido a fatores como o servidor estar localizado no exterior.

O problema de latência do Nagle não ocorreu com o WebSocket no Chrome para Android, o 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 teve 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, então ele evita essa latência fazendo com que o lado do servidor envie dados em intervalos curtos (a cada 50 ms ou mais). Acredito que receber ACK em intervalos curtos leva o algoritmo Nagle a pensar que não há problema em enviar dados.

Algoritmo Nagle 1

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

Algoritmo Nagle 2

Por outro lado, o gráfico mostra os resultados do uso do servidor nos EUA. Enquanto os intervalos de saída verdes se mantêm estáveis a 100 ms, os intervalos de entrada flutuam entre as mínimas de 0 ms e as máximas de 500 ms, indicando que o PC está recebendo dados em blocos.

ALT_TEXT_HERE

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

Um bug?

Apesar de o navegador padrão do Android 4 (ICS) ter uma API WebSocket, ele não consegue se conectar, resultando em um evento Socket.IO connect_failed. Internamente, ela expira 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 redirecionamento não é tão complicada, não deve ser difícil escalonar verticalmente e aumentar o número de servidores, desde que você garanta que o mesmo PC e o mesmo dispositivo móvel estejam sempre conectados ao mesmo servidor.

Física

O movimento de bolas no jogo (rolar ladeira, colisão com o chão, colisão com paredes, coletar itens etc.) é feito com um simulador de física em 3D. Usei o Ammo.js, uma porta do mecanismo de física amplamente usado Bullet para JavaScript que utiliza o Emscripten, junto com o Physijs para usá-lo como um "Web Worker".

Web workers

Web Workers é uma API para executar JavaScript em threads separados. O JavaScript iniciado como Web Worker é executado como um encadeamento separado do que o chamou originalmente, de modo que tarefas pesadas possam ser executadas mantendo a página responsiva. A Physijs usa Web Workers com eficiência para que o motor de física 3D, que é normalmente intensivo, funcione sem problemas. O World Wide Maze lida com o mecanismo de física e a renderização de imagens WebGL em frame rates completamente diferentes. Assim, mesmo que o frame rate caia em uma máquina de baixa especificação devido à carga pesada de renderização WebGL, o próprio mecanismo de física vai manter mais ou menos 60 QPS e não vai impedir os controles do jogo.

QPS

Esta imagem mostra os frame rates resultantes em um Lenovo G570. A caixa de cima mostra o frame rate para WebGL (renderização de imagem), e a inferior mostra o frame rate para o mecanismo de física. A GPU é um chip Intel HD Graphics 3000 integrado, portanto, o frame rate de renderização de imagem não atingiu os 60 fps esperados. No entanto, como o mecanismo de física atingiu o frame rate esperado, a jogabilidade não é tão diferente do desempenho em uma máquina de alta especificação.

Como os threads com Web Workers ativos não têm objetos de console, os dados precisam ser enviados para o thread principal via postMessage para produzir registros de depuração. O uso de console4Worker cria o equivalente a um objeto do console no worker, facilitando significativamente o processo de depuração.

Service workers

As versões recentes do Chrome permitem que você defina pontos de interrupção ao iniciar Web Workers, o que também é útil para depuração. Ele pode ser encontrado no painel "Workers" das Ferramentas para desenvolvedores.

Desempenho

Às vezes, as fases com alta contagem de polígonos excedem 100.000 polígonos, mas a performance não foi afetada, mesmo quando foram geradas totalmente como Physijs.ConcaveMesh (btBvhTriangleMeshShape no marcador).

Inicialmente, o frame rate caiu à medida que o número de objetos que precisavam de detecção de colisão aumentava, mas a eliminação do processamento desnecessário no Physijs melhorou o desempenho. Essa melhoria foi feita em um bifurcação do Physijs original.

Objetos fantasmas

Os objetos que têm detecção de colisão, mas não causam impacto sobre a colisão e, portanto, não afetam outros objetos, são chamados de "objetos fantasma" no marcador. Embora o Physijs não ofereça suporte oficialmente a objetos fantasma, é possível criá-los lá mexendo nas flags depois de gerar um Physijs.Mesh. O World Wide Maze usa objetos fantasma para a detecção de colisão de itens e pontos de objetivo.

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, Stack Overflow ou Documentação do Bullet para mais informações. Como o Physijs é um wrapper para o Ammo.js, e o Ammo.js é basicamente idêntico ao Bullet, a maioria das coisas que podem ser feitas no Bullet também podem ser feitas no Physijs.

O problema do Firefox 18

A atualização do Firefox da versão 17 para a 18 mudou a forma como os Web Workers trocavam 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 de que o World Wide Maze é composto por várias estruturas diferentes de código aberto. Estou escrevendo este artigo na esperança de oferecer algum tipo de feedback.

asm.js

Embora isso não afete diretamente o World Wide Maze, a Ammo.js já é compatível com o recém-anunciado asm.js pelo Mozilla, o que não surpreende, já que o asm.js foi basicamente criado para acelerar o JavaScript gerado pelo Emscripten, e o criador do Emscripten também é o criador do Ammo.js. Se o Chrome também for compatível com asm.js, a carga de computação do mecanismo de física deverá diminuir consideravelmente. A velocidade foi visivelmente mais rápida quando testada com o Firefox Nightly. Talvez seja melhor escrever seções que exigem mais velocidade em C/C++ e transferi-las para JavaScript usando Emscripten.

WebGL

Para a implementação do WebGL, usei a biblioteca mais desenvolvida, a three.js (r53). Embora a revisão 57 já tenha sido lançada nos últimos estágios de desenvolvimento, mudanças importantes foram feitas na API, então eu mantive 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 de Kawase MGF". No entanto, enquanto o Método Kawase faz todas as áreas claras florescerem, o World Wide Maze cria destinos de renderização separados para áreas que precisam brilhar. Isso ocorre porque uma captura de tela do site precisa ser usada para texturas do cenário, e simplesmente extrair todas as áreas claras faria com que todo o site brilhasse se, por exemplo, ele tivesse um fundo branco. Também considerei processar tudo em HDR, mas decidi não fazer isso desta vez, já que a implementação teria sido bastante complicada.

Glow

O canto superior esquerdo mostra o primeiro passe, em que as áreas de brilho foram renderizadas separadamente e depois um desfoque aplicado. O canto inferior direito mostra a segunda passagem, em que o tamanho da imagem foi reduzido em 50% e, em seguida, um desfoque é aplicado. O canto superior direito mostra a terceira passagem, em que a imagem foi novamente reduzida em 50% e depois 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, então ainda há espaço para mais 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 ambientais. Os mapas de ambiente precisam ser atualizados sempre 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 simples quanto atualizar cada quadro, mas a diferença é praticamente imperceptível, a menos que seja apontada.

Sombreador, sombreador, sombreador...

O WebGL requer sombreadores (sombreadores de vértice e de fragmento) para toda a renderização. Embora os sombreadores incluídos no three.js já permitam uma ampla variedade de efeitos, é inevitável criar seus próprios efeitos 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 na linguagem de sombreamento (GLSL), mesmo quando o processamento da CPU (via JavaScript) seria mais fácil. Naturalmente, os efeitos das ondas do oceano dependem de sombreadores, assim como os fogos de artifício nos pontos do objetivo e o efeito de malha usado quando a bola aparece.

Bolas de sombreador

O exemplo acima é de testes do efeito de malha usado quando a bola é exibida. O da esquerda é aquele usado no jogo, composto por 320 polígonos. O que está 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 sombreadores pode manter um frame rate estável de 30 QPS.

Malha de sombreador

Os itens pequenos espalhados pelo cenário são todos integrados em uma malha, e o movimento individual depende de sombreadores que movem cada uma das dicas do polígono. Isso serve para testar se o desempenho seria afetado com um grande número de objetos presentes. Há cerca de 5.000 objetos, compostos por aproximadamente 20.000 polígonos. O desempenho não foi afetado.

poly2tri

Os estágios são formados com base nas informações de contorno recebidas do servidor e depois poligonizadas pelo JavaScript. A triangulação, uma parte fundamental desse processo, é implementada incorretamente pelo three.js e geralmente falha. Por isso, decidi integrar outra biblioteca de triangulação chamada poly2tri. No passado, o three.js claramente tentou fazer a mesma coisa no passado, então eu fiz a tarefa simplesmente comentando parte dela. Os erros diminuíram significativamente como resultado, permitindo muitas outras fases jogáveis. O erro ocasional persiste e, por algum motivo, o poly2tri lida com erros emitindo alertas, então a modifiquei 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 isotrópico MIP padrão diminui as imagens nos eixos horizontal e vertical, a visualização de polígonos de ângulos oblíquos faz com que as texturas na extremidade do cenário do World Wide Maze pareçam texturas horizontalmente alongadas e de baixa resolução. A imagem no canto superior direito nesta página da Wikipédia mostra um bom exemplo disso. Na prática, é necessária uma resolução mais 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 também menciona este artigo das práticas recomendadas do WebGL, a maneira mais importante de melhorar o desempenho do WebGL (OpenGL) é minimizar as chamadas de desenho. Durante o desenvolvimento inicial do World Wide Maze, todas as ilhas, pontes e proteções no jogo eram objetos separados. Isso às vezes resultava em mais de 2 mil chamadas de desenho, dificultando o gerenciamento de fases complexas. No entanto, depois de reunir os mesmos tipos de objetos em uma única malha, as chamadas de desenho diminuíram para cinquenta ou mais, melhorando significativamente o desempenho.

Usei o recurso de rastreamento do Chrome para mais otimização. Os criadores de perfil incluídos nas Ferramentas para desenvolvedores do Chrome podem determinar até certo ponto os tempos gerais de processamento do método, mas o rastreamento pode informar precisamente quanto tempo cada parte leva, até um milésimo de segundo. Consulte este artigo para ver detalhes sobre como usar o rastreamento.

Otimização

Os itens acima são resultados de rastros da criação de mapas de ambiente para o reflexo da bola. Inserir console.time e console.timeEnd em locais aparentemente relevantes no three.js nos fornece um gráfico semelhante a este. O tempo flui da esquerda para a direita, e cada camada é como uma pilha de chamadas. O aninhamento de um console.time em um console.time permite medições adicionais. O gráfico superior é de pré-otimização e o inferior é pós-otimização. Como mostrado no gráfico superior, o updateMatrix (embora a palavra seja truncada) foi chamado para cada uma das renderizações de 0 a 5 durante a pré-otimização. No entanto, eu o modifiquei para que seja chamado apenas uma vez, já que esse processo é necessário somente quando os objetos mudam de posição ou orientação.

O processo de rastreamento em si ocupa recursos naturalmente. Portanto, inserir console.time excessivamente pode causar um desvio significativo do desempenho real, dificultando a identificação de áreas para otimização.

Ajustador de desempenho

Devido à natureza da Internet, o jogo provavelmente será jogado em sistemas com especificações muito variadas. O 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 no frame rate, ajudando a garantir uma reprodução suave. O World Wide Maze usa a mesma classe IFLAutomaticPerformanceAdjust e reduz os efeitos na seguinte ordem para tornar a jogabilidade o mais suave possível:

  1. Se o frame rate ficar abaixo de 45 fps, a atualização dos mapas de ambiente será interrompida.
  2. Se ela ainda ficar abaixo de 40 fps, a resolução da renderização será reduzida para 70% (50% da proporção da superfície).
  3. Se ainda estiver abaixo de 40 fps, o FXAA (anti-aliasing) será eliminado.
  4. Se ela ainda estiver abaixo de 30 fps, os efeitos de brilho serão eliminados.

Vazamento de memória

Eliminar objetos de forma organizada é um tipo de incômodo com o three.js. Mas deixá-los sozinhos obviamente levaria a vazamentos de memória, então elaborei o método abaixo. @renderer refere-se a THREE.WebGLRenderer. A revisão mais recente do three.js usa um método um pouco diferente de desalocação, então provavelmente não funcionará com ela da forma como se encontra.

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 sobre o aplicativo WebGL é a capacidade de criar layout de página em HTML. A criação de interfaces 2D, como exibições de pontuação ou texto em Flash ou openFrameworks (OpenGL) é bem difícil. O Flash tem pelo menos um IDE, mas o openFrameworks é difícil se você não está acostumado com ele (usar algo como o Cocos2D pode facilitar isso). 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. Ainda que efeitos complexos, como a condensação de partículas em um logotipo, sejam impossíveis, alguns efeitos 3D dentro dos recursos das Transforms de 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 de CSS (implementada com o Google Transit). Obviamente, as gradações de segundo plano usam WebGL.

Cada página do jogo (título, RESULTADO, CLASSIFICAÇÃO etc.) tem o próprio arquivo HTML. Quando elas são carregadas como modelos, a função $(document.body).append() é chamada com os valores adequados no momento adequado. Um problema foi que não foi possível definir eventos de mouse e teclado antes da anexação. Portanto, tentar el.click (e) -> console.log(e) antes da anexação não funcionou.

Internacionalização (i18n)

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

A edição e a tradução de textos inseridos no jogo eram feitas na planilha Google Docs. Como o i18next requer arquivos JSON, exportei as planilhas para TSV e as converta com um conversor personalizado. Fiz muitas atualizações pouco antes do lançamento, portanto, automatizar o processo de exportação das Planilha Google Docs teria tornado as coisas muito mais fáceis.

O recurso de tradução automática do Google Chrome também funciona normalmente, pois as páginas são desenvolvidas com HTML. No entanto, às vezes ele não detecta o idioma corretamente, confundindo o idioma com um totalmente diferente (por exemplo, vietnamita). Por isso, esse recurso está desativado no momento. Ela pode ser desativada com metatags.

RequireJS

Escolhi RequireJS como meu sistema de módulos JavaScript. As 10.000 linhas de código-fonte do jogo estão divididas em cerca de 60 classes (arquivos de café) e compiladas em arquivos js individuais. O requireJS carrega esses arquivos individuais na ordem apropriada 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, hoge.js precisa ser carregado antes de moge.js. Além disso, como "hoge" é designado como o primeiro argumento de "define", o hoge.js é sempre carregado primeiro (chamado de volta depois do carregamento do hoge.js). Esse mecanismo é chamado AMD, e qualquer biblioteca de terceiros pode ser usada para o mesmo tipo de callback, desde que seja compatível com AMD. Mesmo aqueles que não têm (por exemplo, three.js) terão desempenho semelhante, desde que as dependências sejam especificadas com antecedência.

Esse processo é 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 e o reduz usando o UglifyJS (ou 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 para o World Wide Maze é de cerca de 2 MB e pode ser reduzido para cerca de 1 MB com a otimização r.js. Se o jogo pudesse ser distribuído usando gzip, isso seria reduzido ainda mais 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 atualmente descompactado como 1 MB de texto simples.

Criador de fases

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

  1. O URL do site a ser convertido em um cenário é enviado por WebSocket.
  2. O PhantomJS faz 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 de elementos HTML, um programa C++ (OpenCV, Boost) personalizado exclui áreas desnecessárias, gera ilhas, conecta as ilhas com pontes, calcula as posições da coluna de proteção e dos itens, define o ponto de objetivo etc. Os resultados são gerados no formato JSON e retornados ao navegador.

PhantomJS

O PhantomJS é um navegador que não exige tela. Com ele, é possível carregar páginas da Web sem abrir janelas. Assim, ele pode ser usado em testes automatizados ou para fazer capturas de tela no lado do servidor. Seu mecanismo de navegador é o WebKit, o mesmo usado pelo Chrome e pelo Safari, de modo que seu layout e os resultados de execução de JavaScript também são mais ou menos os mesmos dos navegadores padrão.

Com o PhantomJS, o JavaScript ou o CoffeeScript é usado para escrever os processos que você quer que sejam executados. É 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 fontes em japonês (M+ FONTS). Mesmo assim, a renderização de fontes é tratada de forma diferente do que no Windows ou Mac OS, de modo que a mesma fonte pode parecer diferente em outras máquinas (no entanto, a diferença é mínima).

Basicamente, a recuperação das posições de tags img e div é realizada da mesma forma que em páginas padrão. A jQuery também pode ser usada sem problemas.

stage_builder

Inicialmente, pensei em 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. Mas no final eu escolhi uma abordagem de processamento de imagens. Para isso, criei um programa em 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 medidas de proteção.
  8. Gera dados de posicionamento no formato JSON.

Todas as etapas estão detalhadas abaixo.

Carregando 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 picojson parecia a mais fácil de trabalhar.

Conversão de imagens e texto em "ilhas"

Criação do estágio

Veja acima uma captura de tela da seção "Notícias" do 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 fundo branca, ou seja, a cor mais predominante na captura de tela. Esta é a aparência do processo:

Criação do estágio

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

O texto é muito refinado 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, portanto, preencheremos essas áreas com branco, com base na saída de dados da tag img do PhantomJS. A imagem resultante será semelhante a esta:

Criação do estágio

O texto agora forma aglomerados adequados, e cada imagem é uma ilha adequada.

Criação de pontes para conectar as ilhas

Assim que as ilhas estiverem prontas, elas serão conectadas com pontes. Cada ilha procura ilhas adjacentes à esquerda, à direita, acima e abaixo e depois conecta uma ponte ao ponto mais próximo da ilha mais próxima, resultando em algo assim:

Criação do estágio

Eliminar pontes desnecessárias para criar um labirinto

Manter todas as pontes tornaria o estágio muito fácil de navegar, portanto, algumas devem ser eliminadas para criar um labirinto. Uma ilha (por exemplo, a do canto superior esquerdo) é escolhida como ponto de partida, e todas as pontes (selecionadas aleatoriamente) que se conectam a ela são excluídas, exceto uma. O mesmo processo é feito 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 retorna a um ponto que permite acesso a uma nova ilha. O labirinto estará concluído quando todas as ilhas forem processadas dessa forma.

Criação do estágio

Colocar itens grandes

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

Criação do estágio

De todos esses pontos possíveis, o no canto superior esquerdo é definido como o ponto de partida (círculo vermelho), o no canto inferior direito é definido como a meta (círculo verde), e um máximo de seis do restante é escolhido para o posicionamento de itens grandes (círculo roxo).

Colocar itens pequenos

Criação do estágio

Um número adequado de itens pequenos é posicionado ao longo de linhas a distâncias definidas a partir das margens da ilha. A imagem acima (não de aid-dcc.com) mostra as linhas de posicionamento projetadas em cinza, deslocadas e posicionadas em intervalos regulares a partir das margens da ilha. Os pontos vermelhos indicam onde os itens pequenos são colocados. Como esta imagem é de uma versão de desenvolvimento intermediário, os itens são dispostos em linhas retas, mas a versão final os dispersa um pouco mais irregularmente em ambos os lados das linhas cinza.

Como posicionar proteções

As proteções são basicamente posicionadas ao longo dos limites externos das ilhas, mas precisam ser interrompidas em 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 dos limites da ilha se cruzam com as linhas em ambos os lados de uma ponte.

Criação do estágio

As linhas verdes que contornam as ilhas são a proteção. A imagem pode ser difícil de ver, mas não há linhas verdes onde as pontes estão. Essa é a imagem final usada para depuração, em que estão incluídos todos os objetos que precisam ser enviados para JSON. Os pontos azuis claros são itens pequenos, e os pontos cinza são pontos propostos para reinício. 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.

Saída de dados de posicionamento no formato JSON

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

Como 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, 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 criei um arquivo de configuração usando automake/autoconf para que o build pudesse ser compilado no Linux. Tive que usar "configure && make" no Linux para criar o arquivo executável. Encontrei alguns bugs específicos do Linux devido a diferenças de versão do compilador, mas consegui resolvê-los com relativa facilidade usando o gdb.

Conclusão

Um jogo como esse poderia ser criado com Flash ou Unity, o que traria inúmeras vantagens. No entanto, essa versão não exige plug-ins, e os recursos de layout do HTML5 + CSS3 são extremamente eficientes. É muito importante ter as ferramentas certas para cada tarefa. Fiquei pessoalmente surpreso com o desempenho do jogo feito inteiramente em HTML5 e, embora ele ainda não tenha muitas áreas, estou ansioso para ver como ele se desenvolverá no futuro.