Caso de éxito: Inside World Wide Maze

World Wide Maze es un juego en el que puedes usar tu smartphone para navegar por una bola que roda por laberintos en 3D creados a partir de sitios web con el fin de intentar alcanzar sus puntos objetivo.

World Wide Maze

El juego utiliza muchas funciones HTML5. Por ejemplo, el evento DeviceOrientation recupera los datos de inclinación del smartphone, que luego se envían a la PC a través de WebSocket, donde los jugadores acceden a espacios 3D creados por WebGL y Web Workers.

En este artículo, explicaremos con precisión cómo se usan estas funciones, el proceso general de desarrollo y los puntos clave para la optimización.

DeviceOrientation

El evento DeviceOrientation (ejemplo) se utiliza para recuperar los datos de inclinación del smartphone. Cuando se utiliza addEventListener con el evento DeviceOrientation, se invoca una devolución de llamada con el objeto DeviceOrientationEvent como argumento en intervalos regulares. Los intervalos varían según el dispositivo que se utilice. Por ejemplo, en iOS + Chrome y en iOS + Safari, la devolución de llamada se invoca aproximadamente cada 1/20 de segundo, mientras que en Android 4 + Chrome se invoca aproximadamente cada décimo de segundo.

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

El objeto DeviceOrientationEvent contiene datos de inclinación para cada uno de los ejes X, Y y Z en grados (no radianes) (obtén más información en HTML5Rocks). Sin embargo, los valores que se devuelven también varían según la combinación del dispositivo y el navegador que se usa. Los rangos de los valores reales que se devuelven se presentan en la siguiente tabla:

Orientación del dispositivo.

Los valores en la parte superior destacados en azul son los definidos en las especificaciones W3C. Los destacados en verde coinciden con estas especificaciones, mientras que los destacados en rojo se desvían. Sorprendentemente, solo la combinación Android-Firefox mostró valores que coincidían con las especificaciones. No obstante, cuando se trata de la implementación, tiene más sentido adaptar los valores que ocurren con frecuencia. Por lo tanto, World Wide Maze usa los valores de retorno de iOS como estándar y se ajusta a los dispositivos Android según corresponda.

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

Sin embargo, todavía no es compatible con Nexus 10. Si bien la Nexus 10 devuelve el mismo rango de valores que otros dispositivos Android, hay un error que revierte los valores beta y gamma. Esto se abordará por separado. (¿Tal vez utiliza la orientación horizontal de forma predeterminada?)

Como esto demuestra, incluso si las APIs que involucran dispositivos físicos tienen especificaciones establecidas, no hay garantía de que los valores que se muestran coincidan con esas especificaciones. Por lo tanto, es fundamental probarlas en todos los dispositivos potenciales. También significa que se pueden ingresar valores inesperados, lo que requiere crear soluciones alternativas. World Wide Maze solicita a los jugadores nuevos que calibren sus dispositivos como el paso 1 de este instructivo, pero no se calibrará a la posición cero correctamente si recibe valores de inclinación inesperados. Por lo tanto, tiene un límite de tiempo interno y le pide al reproductor que cambie a los controles del teclado si no puede calibrarlo dentro de ese límite de tiempo.

WebSocket

En World Wide Maze, tu smartphone y tu PC están conectados a través de WebSocket. Dicho de un modo más preciso, están conectados entre ellos a través de un servidor de retransmisión, es decir, de smartphone a servidor a PC. Esto se debe a que WebSocket no tiene la capacidad de conectar navegadores directamente entre sí. (El uso de canales de datos WebRTC permite la conectividad entre pares y elimina la necesidad de un servidor de retransmisión; sin embargo, al momento de la implementación, este método solo se puede utilizar con Chrome Canary y Firefox Nightly).

Elegí la implementación con una biblioteca llamada Socket.IO (v0.9.11), que incluye funciones para volver a conectarse en caso de que se agote el tiempo de espera o la desconexión de la conexión. Lo usé junto con NodeJS, ya que esta combinación de NodeJS + Socket.IO mostró el mejor rendimiento del servidor en varias pruebas de implementación de WebSocket.

Vinculación por números

  1. Tu PC se conectará al servidor.
  2. El servidor le da a tu PC un número generado al azar y recuerda la combinación de número y PC.
  3. En tu dispositivo móvil, especifica un número y conéctate al servidor.
  4. Si el número especificado es el mismo que el de una PC conectada, el dispositivo móvil se sincronizará con esa PC.
  5. Si no hay una PC designada, se produce un error.
  6. Cuando los datos provienen de tu dispositivo móvil, se envían a la PC con la que están vinculados y viceversa.

También puedes realizar la conexión inicial desde tu dispositivo móvil. En ese caso, los dispositivos simplemente se invierten.

Sincronización de pestañas

La función de Sincronización de pestañas específica de Chrome facilita aún más el proceso de vinculación. Con él, las páginas que se abren en la PC se pueden abrir fácilmente en un dispositivo móvil (y viceversa). La PC toma el número de conexión que emite el servidor y lo adjunta a la URL de una página mediante history.replaceState.

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

Si la Sincronización de pestañas está habilitada, la URL se sincroniza después de unos segundos y se puede abrir la misma página en el dispositivo móvil. El dispositivo móvil verifica la URL de la página abierta y, si se agrega un número, comienza a conectarse de inmediato. Esto elimina la necesidad de ingresar números manualmente o escanear códigos QR con una cámara.

Latencia

Dado que el servidor de retransmisión está ubicado en EE.UU., el acceso a él desde Japón genera un retraso de aproximadamente 200 ms antes de que los datos de inclinación del smartphone lleguen a la PC. Los tiempos de respuesta fueron claramente lentos en comparación con los del entorno local utilizado durante el desarrollo, pero la inserción de algo como un filtro de paso bajo (usé EMA) mejoró esto a niveles más discretos. (En la práctica, también se necesitaba un filtro de paso bajo para fines de presentación; los valores de retorno del sensor de inclinación incluían una cantidad considerable de ruido y la aplicación de esos valores a la pantalla provocaba un gran sacudón). Esto no funcionó con los saltos, que eran claramente lentos, pero no se pudo hacer nada para resolverlo.

Como esperaba problemas de latencia desde el principio, consideré configurar servidores de retransmisión en todo el mundo para que los clientes pudieran conectarse al más cercano disponible (y así minimizar la latencia). Sin embargo, terminé usando Google Compute Engine (GCE), que solo existía en EE.UU. en ese momento, por lo que no era posible.

Problema con el algoritmo Nagle

Por lo general, el algoritmo de Nagle se incorpora a sistemas operativos para lograr una comunicación eficiente mediante el almacenamiento en búfer a nivel de TCP. Sin embargo, noté que no podía enviar datos en tiempo real mientras estaba habilitado. (específicamente cuando se combina con la recuperación retrasada de TCP. Incluso sin ACK retrasados, el mismo problema ocurre si ACK se retrasa hasta cierto punto debido a factores como que el servidor se encuentre en el extranjero).

El problema de latencia de Nagle no se produjo con WebSocket en Chrome para Android, que incluye la opción TCP_NODELAY para inhabilitar Nagle, pero sí con WebKit WebSocket, que no tiene habilitada esta opción. (Safari, que usa el mismo WebKit, también tuvo este problema. El problema se informó a Apple a través de Google y, al parecer, se resolvió en la versión de desarrollo de WebKit.

Cuando ocurre este problema, los datos de inclinación que se envían cada 100 ms se combinan en fragmentos que solo llegan a la PC cada 500 ms. El juego no puede funcionar en estas condiciones, por lo que evita esta latencia al hacer que el servidor envíe datos en intervalos cortos (cada 50 ms aproximadamente). Creo que recibir ACK a intervalos cortos hace que el algoritmo de Nagle piense que está bien enviar datos.

Algoritmo de Nagle 1

Los gráficos de arriba representan los intervalos de datos reales recibidos. Indica los intervalos de tiempo entre paquetes; el verde representa los intervalos de salida y el rojo, los intervalos de entrada. El mínimo es de 54 ms, el máximo es de 158 ms y el medio es cercano a los 100 ms. Aquí usé un iPhone con un servidor de retransmisión ubicado en Japón. Tanto la salida como la entrada duran alrededor de 100 ms y la operación es fluida.

Algoritmo de Nagle 2

En cambio, en este gráfico se muestran los resultados del uso del servidor en EE.UU. Mientras que los intervalos de salida verdes se mantienen estables en 100 ms, los intervalos de entrada oscilan entre mínimos de 0 ms y máximos de 500 ms, lo que indica que la PC recibe datos en fragmentos.

ALT_TEXT_HERE

Por último, en este gráfico, se muestran los resultados de evitar la latencia haciendo que el servidor envíe datos de marcador de posición. Si bien su rendimiento no es tan bueno como el uso del servidor japonés, está claro que los intervalos de entrada se mantienen relativamente estables en alrededor de 100 ms.

¿Un error?

A pesar de que el navegador predeterminado en Android 4 (ICS) tenga una API de WebSocket, no se puede conectar, lo que genera un evento connect_failed de Socket.IO. Se agota el tiempo de espera de forma interna, y el servidor tampoco puede verificar la conexión. (No he probado esto solo con WebSocket, por lo que podría ser un problema de Socket.IO).

Escala servidores de retransmisión

Dado que la función del servidor de retransmisión no es tan complicada, escalar verticalmente y aumentar la cantidad de servidores no debería ser difícil siempre y cuando se aseguren de que la misma PC y el mismo dispositivo móvil estén siempre conectados al mismo servidor.

Física

El movimiento de la pelota dentro del juego (rodar cuesta abajo, colisionar con el suelo, chocar con paredes, recolectar objetos, etc.) se realiza con un simulador físico en 3D. Usé Ammo.js, un puerto del motor de física Bullet que se usa mucho en JavaScript con Emscripten, junto con Physijs para usarlo como "Web Worker".

Trabajadores web

Web Workers es una API para ejecutar JavaScript en subprocesos separados. JavaScript que se inicia como trabajador web se ejecuta como un subproceso independiente del que lo llamó originalmente, por lo que es posible realizar tareas pesadas sin afectar la capacidad de respuesta de la página. Physijs usa Web Workers de forma eficiente para ayudar a que el motor de física 3D, que suele ser intensivo, se ejecute sin problemas. World Wide Maze controla el motor físico y la renderización de imágenes de WebGL a velocidades de fotogramas completamente diferentes, por lo que, incluso si la velocidad de fotogramas disminuye en una máquina de baja especificación debido a la carga pesada de renderización de WebGL, el motor físico mantendrá más o menos 60 FPS y no impedirá los controles del juego.

MPS

En esta imagen, se muestran las velocidades de fotogramas resultantes en un dispositivo Lenovo G570. El cuadro superior muestra la velocidad de fotogramas de WebGL (renderización de imágenes), y el inferior muestra la velocidad de fotogramas del motor de física. La GPU es un chip Intel HD Graphics 3000 integrado, por lo que la velocidad de procesamiento de la imagen no alcanzó los 60 FPS esperados. Sin embargo, dado que el motor físico alcanzó la velocidad de fotogramas esperada, el juego no es muy diferente al rendimiento en una máquina de alta especificación.

Debido a que los subprocesos con Web Workers activos no tienen objetos de la consola, los datos deben enviarse al subproceso principal a través de postMessage para generar registros de depuración. El uso de console4Worker crea el equivalente de un objeto de consola en el trabajador, lo que facilita considerablemente el proceso de depuración.

Trabajadores de servicio

Las versiones recientes de Chrome te permiten establecer puntos de interrupción al iniciar Web Workers, lo que también es útil para la depuración. Puedes encontrarlo en el panel "Workers" de las Herramientas para desarrolladores.

Rendimiento

En ocasiones, las etapas con altos recuentos de polígonos superan los 100,000, pero el rendimiento no se vio afectado incluso cuando se generaron por completo como Physijs.ConcaveMesh (btBvhTriangleMeshShape en viñeta).

Inicialmente, la velocidad de fotogramas disminuyó a medida que aumentaba la cantidad de objetos que requerían detección de colisiones, pero la eliminación del procesamiento innecesario en Physijs mejoró el rendimiento. Esta mejora se realizó en una bifurcación del Physijs original.

Objetos fantasma

En la viñeta, los objetos que tienen detección de colisiones, pero que no tienen impacto en ellas y, por lo tanto, no tienen efecto en otros objetos. Aunque Physijs no admite oficialmente objetos fantasma, es posible crearlos allí jugando con las marcas después de generar una Physijs.Mesh. World Wide Maze usa objetos fantasma para detectar objetos y puntos de objetivo por colisión.

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

Para collision_flags, 1 es CF_STATIC_OBJECT y 4 es CF_NO_CONTACT_RESPONSE. Para obtener más información, busca en la documentación del foro, Stack Overflow o Bullet. Dado que Physijs es un wrapper para Ammo.js, y Ammo.js es básicamente idéntico a Bullet, por lo que la mayoría de las acciones que se pueden realizar en Bullet también se pueden realizar en Physijs.

El problema de Firefox 18

La actualización de Firefox de 17 a 18 cambió la forma en que los trabajadores web intercambiaban datos y, como resultado, Physijs dejó de funcionar. El problema se informó en GitHub y se resolvió después de unos días. Si bien esta eficiencia de código abierto me impresionó, el incidente también me recordó que World Wide Maze se compone de varios frameworks de código abierto diferentes. Le escribo este artículo para brindarme algún tipo de comentario.

asm.js

Aunque esto no afecta directamente a World Wide Maze, Ammo.js ya es compatible con asm.js, anunciado recientemente por Mozilla (no sorprendente, ya que asm.js se creó básicamente para acelerar el código JavaScript que Emscripten genera y el creador de Emscripten también es el creador de Ammo.js). Si Chrome también admite asm.js, la carga de procesamiento del motor de física debería disminuir considerablemente. La velocidad era notablemente más rápida cuando se probó con Firefox Nightly. ¿Quizás sea mejor escribir secciones que requieran más velocidad en C/C++ y luego trasladarlas a JavaScript con Emscripten?

WebGL

Para la implementación de WebGL, usé la biblioteca más desarrollada de forma activa: three.js (r53). Si bien la revisión 57 ya se lanzó en las últimas etapas de desarrollo, se realizaron cambios importantes en la API, por lo que dejé la revisión original para el lanzamiento.

Efecto de resplandor

El efecto de brillo que se agrega al núcleo de la pelota y a los elementos se implementa con una versión simple del llamado "Kawase Method MGF". Sin embargo, aunque el método de Kawase florece todas las áreas brillantes, World Wide Maze crea objetivos de renderización independientes para las áreas que necesitan iluminarse. Esto se debe a que una captura de pantalla del sitio web se debe utilizar para las texturas de las etapas, y simplemente extraer todas las áreas brillantes hará que brille todo el sitio web si, por ejemplo, tiene un fondo blanco. También consideré procesar todo en HDR, pero esta vez decidí no hacerlo, ya que la implementación se habría vuelto bastante complicada.

Resplandor

En la parte superior izquierda, se muestra el primer pase, en el que las áreas de brillo se renderizaron por separado y, luego, se aplicó un desenfoque. La parte inferior derecha muestra el segundo pase, en el que el tamaño de la imagen se redujo en un 50% y, luego, se aplicó un desenfoque. La parte superior derecha muestra el tercer pase, en el que la imagen se redujo en un 50% y, luego, se desenfocó. Luego, los tres se superpusieron para crear la imagen compuesta final que se muestra en la parte inferior izquierda. Para el desenfoque, usé VerticalBlurShader y HorizontalBlurShader, incluidos en tres.js, por lo que aún hay espacio para una mayor optimización.

Pelota reflectante

La reflexión sobre la pelota se basa en un ejemplo de tres.js. Todas las direcciones se representan a partir de la posición de la pelota y se usan como mapas de entorno. Los mapas de entorno deben actualizarse cada vez que se mueve la pelota, pero como la actualización a 60 FPS es intensa, se actualizan cada tres fotogramas. El resultado no es tan sencillo como actualizar cada fotograma, pero la diferencia es prácticamente imperceptible a menos que se indique lo contrario.

Sombreador, sombreador, sombreador...

WebGL requiere sombreadores (sombreadores de vértices y fragmentos) para toda la renderización. Si bien los sombreadores incluidos en 3.js ya permiten una amplia variedad de efectos, es inevitable que escribas los tuyos para aplicar matices y optimizaciones más elaborados. Dado que World Wide Maze mantiene la CPU ocupada con su motor físico, intenté usar la GPU escribiendo tanto como fuera posible en lenguaje de sombreado (GLSL), incluso cuando el procesamiento de la CPU (a través de JavaScript) hubiera sido más fácil. Los efectos de las olas del océano se basan en sombreadores, al igual que los fuegos artificiales en los puntos de meta y el efecto de malla que se usa cuando aparece la bola.

Bolas de sombreado

Lo anterior corresponde a pruebas del efecto de malla que se usan cuando aparece la pelota. El que se encuentra a la izquierda es el que se usa en el juego y está compuesto por 320 polígonos. El que está en el centro usa alrededor de 5,000 polígonos, y el de la derecha, unos 300,000 polígonos. Incluso con esta cantidad de polígonos, el procesamiento con sombreadores puede mantener una velocidad de fotogramas constante de 30 FPS.

Malla de sombreadores

Los elementos pequeños que están dispersos por el escenario están integrados en una malla, y el movimiento individual se basa en que los sombreadores muevan cada una de las puntas del polígono. Esto es de una prueba para ver si el rendimiento se vería afectado con una gran cantidad de objetos presentes. Aquí se colocan alrededor de 5,000 objetos, compuestos por unos 20,000 polígonos. El rendimiento no se vio afectado en absoluto.

poly2tri

Las etapas se forman según la información del contorno que se recibe del servidor y, luego, JavaScript las poligusa. La triangulación, una parte clave de este proceso, se implementa de manera deficiente por parte de tres.js y suele fallar. Por lo tanto, decidí integrar y mismo una biblioteca de triangulación diferente llamada poly2tri. Al parecer, 3.js ya había intentado hacer lo mismo en el pasado, así que hice que todo funcionara con solo comentar una parte. Como resultado, los errores disminuyeron significativamente, lo que permitió muchas más etapas reproducibles. El error ocasional persiste y, por algún motivo, poly2tri maneja los errores emitiendo alertas, por lo que lo modifiqué para que genere excepciones.

poly2tri

Lo anterior muestra cómo se triangula el contorno azul y cómo se generan los polígonos rojos.

Filtrado anisotrópico

Dado que el mapeo MIP isotrópico estándar reduce el tamaño de las imágenes en los ejes horizontales y verticales, la visualización de polígonos desde ángulos oblicuos hace que las texturas en el extremo más alejado de las etapas de World Wide Maze parezcan texturas de baja resolución alargadas horizontalmente. La imagen de la parte superior derecha de esta página de Wikipedia muestra un buen ejemplo de esto. En la práctica, se requiere una mayor resolución horizontal, que WebGL (OpenGL) resuelve usando un método llamado filtrado anisotrópico. En tres.js, configurar un valor superior a 1 para THREE.Texture.anisotropy habilita el filtrado anisotrópico. Sin embargo, esta función es una extensión y es posible que no sea compatible con todas las GPU.

Optimiza

Como se menciona en este artículo sobre las prácticas recomendadas de WebGL, la forma más importante de mejorar el rendimiento de WebGL (OpenGL) es minimizar las llamadas de dibujo. Durante el desarrollo inicial de World Wide Maze, todas las islas, puentes y barandillas protectoras del juego eran objetos separados. En ocasiones, esto generaba más de 2,000 llamadas, lo que dificultaba el manejo de etapas complejas. Sin embargo, una vez que empaqué los mismos tipos de objetos en una malla, las llamadas de dibujo se redujeron a cincuenta, lo que mejoró el rendimiento de forma significativa.

Usé la función de registro de Chrome para una mayor optimización. Los generadores de perfiles incluidos en las Herramientas para desarrolladores de Chrome pueden determinar los tiempos generales de procesamiento de métodos hasta cierto punto, pero el seguimiento puede indicarte con precisión cuánto tarda cada parte, hasta milésima de segundo. Consulta este artículo para obtener detalles sobre cómo usar el seguimiento.

Optimización

Los anteriores son resultados de seguimiento de la creación de mapas de entorno para el reflejo de la pelota. Si insertas console.time y console.timeEnd en ubicaciones que parecen relevantes en tres.js, obtendrás un gráfico similar al siguiente: El tiempo fluye de izquierda a derecha, y cada capa es similar a una pila de llamadas. Anidar un comando console.time dentro de un console.time permite realizar más mediciones. El gráfico superior es la optimización previa y el inferior es posterior a la optimización. Como se muestra en el gráfico superior, se llamó a updateMatrix (aunque la palabra está truncada) en cada una de las renderizaciones del 0 al 5 durante la optimización previa. Sin embargo, lo modifiqué para que se llame una sola vez, ya que este proceso solo es necesario cuando los objetos cambian de posición o orientación.

Naturalmente, el proceso de seguimiento consume recursos, por lo que la inserción excesiva de console.time puede provocar una desviación significativa del rendimiento real, lo que dificulta la detección de áreas para optimizar.

Ajuste de rendimiento

Debido a la naturaleza de Internet, es probable que el juego se juegue en sistemas con especificaciones ampliamente variables. Find Your Way to Oz, que se lanzó a principios de febrero, usa una clase llamada IFLAutomaticPerformanceAdjust para reducir la escala de los efectos según las fluctuaciones en la velocidad de fotogramas, lo que garantiza una reproducción fluida. World Wide Maze se basa en la misma clase de IFLAutomaticPerformanceAdjust y reduce la escala de los efectos en el siguiente orden para que el juego sea lo más fluido posible:

  1. Si la velocidad de fotogramas es inferior a 45 fps, los mapas de entorno dejarán de actualizarse.
  2. Si aún cae por debajo de los 40 FPS, la resolución de renderización se reduce al 70% (50% de la relación de superficie).
  3. Si aún cae por debajo de 40 fps, se elimina FXAA (suavizado de contorno).
  4. Si la velocidad aún es inferior a 30 fps, se eliminan los efectos de resplandor.

Fuga de memoria

Eliminar objetos de forma prolija es una especie de molestia con tres.js. Sin embargo, al dejarlas solas, obviamente se provocarían fugas de memoria, así que desarrollé el método a continuación. @renderer hace referencia a THREE.WebGLRenderer. (La última revisión de tres.js utiliza un método ligeramente diferente de desasignación, por lo que es probable que no funcione con él tal 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

Personalmente, creo que lo mejor de la aplicación WebGL es la capacidad de diseñar el diseño de la página en HTML. La compilación de interfaces 2D, como puntuaciones o pantallas de texto en Flash o openFrameworks (OpenGL), es una especie de problema. Flash al menos tiene un IDE, pero openFrameworks es difícil si no estás acostumbrado a él (usar algo como Cocos2D puede facilitar el proceso). HTML, por otro lado, permite un control preciso de todos los aspectos de diseño de frontend con CSS, al igual que cuando se crean sitios web. Aunque son imposibles efectos complejos, como la condensación de partículas en un logotipo, algunos efectos 3D dentro de las capacidades de las transformaciones CSS son posibles. Los efectos de texto "GOAL" y "TIME IS UP" de World Wide Maze se animan con la escala en la transición de CSS (implementados con Transit). (Obviamente, las gradaciones de fondo usan WebGL).

Cada página del juego (título, RESULTADO, CLASIFICACIÓN, etc.) tiene su propio archivo HTML y, una vez que se cargan como plantillas, se llama a $(document.body).append() con los valores adecuados en el momento adecuado. Un problema fue que los eventos del mouse y del teclado no se pudieron configurar antes de adjuntar, por lo que intentar el.click (e) -> console.log(e) antes de agregar no funcionó.

Internacionalización (i18n)

Trabajar en HTML también fue conveniente para crear la versión en inglés. Para mis necesidades de internacionalización, decidí usar i18next, una biblioteca web de i18n, que pude usar sin modificaciones.

La edición y traducción del texto del juego se realizó en Documentos de Google. Como i18next requiere archivos JSON, exporté las hojas de cálculo a TSV y, luego, las convertí con un convertidor personalizado. Realicé muchas actualizaciones justo antes del lanzamiento, por lo que automatizar el proceso de exportación desde las hojas de cálculo de Documentos de Google habría facilitado las cosas.

La función de traducción automática de Chrome también funciona con normalidad, ya que las páginas se crean con HTML. Sin embargo, a veces no detecta el idioma correctamente y, en lugar de eso, lo confunde con otro completamente diferente (p.ej., vietnamita), por lo que esta función se encuentra inhabilitada actualmente. (Se puede inhabilitar mediante metaetiquetas).

RequireJS

Elegí RequireJS como sistema de módulos de JavaScript. Las 10,000 líneas de código fuente del juego se dividen en aproximadamente 60 clases (= archivos de café) y se compilan en archivos js individuales. RequireJS carga estos archivos individuales en el orden adecuado según la dependencia.

define ->
  class Hoge
    hogeMethod: ->

La clase definida anteriormente (hoge.coffee) se puede usar de la siguiente manera:

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

Para que funcione, hoge.js se debe cargar antes que moge.js y, dado que "hoge" se designa como el primer argumento de "define", hoge.js siempre se carga primero (se lo vuelve a llamar una vez que hoge.js termina de cargarse). Este mecanismo se denomina AMD, y cualquier biblioteca de terceros se puede usar para el mismo tipo de devolución de llamada, siempre que sea compatible con AMD. Incluso aquellas que no lo tienen (p.ej., tres.js) tendrán un rendimiento similar siempre que las dependencias se especifiquen con anticipación.

Esto es similar a importar AS3, por lo que no debería parecer tan extraño. Si al final encuentras archivos más dependientes, esta es una solución posible.

r.js

RequireJS incluye un optimizador llamado r.js. Esto agrupa el js principal con todos los archivos js dependientes en uno y, luego, lo reduce con UglifyJS (o Closure Compiler). De este modo, se reduce la cantidad de archivos y la cantidad total de datos que el navegador debe cargar. El tamaño total del archivo JavaScript para World Wide Maze es de aproximadamente 2 MB y puede reducirse a 1 MB con la optimización de r.js. Si el juego se pudiera distribuir mediante gzip, se reduciría aún más a 250 KB. (GAE tiene un problema que no permite la transmisión de archivos gzip de 1 MB o más, por lo que el juego actualmente se distribuye sin comprimir como 1 MB de texto sin formato).

Compilador de etapas

Los datos de las etapas se generan de la siguiente manera y se realizan completamente en el servidor de GCE en EE.UU.:

  1. La URL del sitio web que se convertirá en una etapa se envía a través de WebSocket.
  2. PhantomJS toma una captura de pantalla y las posiciones de las etiquetas div e img se recuperan y se muestran en formato JSON.
  3. Según la captura de pantalla del paso 2 y los datos de posicionamiento de los elementos HTML, un programa personalizado C++ (OpenCV, Boost) borra las áreas innecesarias, genera islas, conecta las islas con puentes, calcula las posiciones de los elementos y las barreras de seguridad, establece el punto objetivo, etc. Los resultados se generan en formato JSON y se devuelven al navegador.

PhantomJS

PhantomJS es un navegador que no requiere pantalla. Puede cargar páginas web sin abrir ventanas, por lo que puede usarse en pruebas automatizadas o para tomar capturas de pantalla del servidor. Su motor del navegador es WebKit, el mismo que usan Chrome y Safari, por lo que los resultados de su diseño y ejecución de JavaScript son más o menos los mismos que los de los navegadores estándar.

Con PhantomJS, se usa JavaScript o CoffeeScript para escribir los procesos que quieres que se ejecuten. Obtener capturas de pantalla es muy sencillo, como se muestra en este ejemplo. Trabajaba en un servidor de Linux (CentOS), así que tuve que instalar fuentes para mostrar el japonés (M+ FUENTES). Aun así, la representación de fuentes se maneja de forma diferente que en Windows o Mac OS, por lo que la misma fuente puede verse diferente en otras máquinas (sin embargo, la diferencia es mínima).

La recuperación de las posiciones de las etiquetas img y div se realiza básicamente del mismo modo que en las páginas estándar. jQuery también se puede utilizar sin problemas.

stage_builder

En un principio, consideré usar un enfoque más basado en DOM para generar etapas (similar a Firefox 3D Inspector) e intenté algo como un análisis DOM en PhantomJS. Sin embargo, al final, decidí usar un enfoque de procesamiento de imágenes. Para ello, escribí un programa en C++ que usa OpenCV y Boost llamado "stage_builder". Realiza lo siguiente:

  1. Carga la captura de pantalla y los archivos JSON.
  2. Convierte imágenes y texto en “islas”.
  3. Crea puentes para conectar las islas.
  4. Elimina los puentes innecesarios para crear un laberinto.
  5. Coloca elementos grandes.
  6. Coloca elementos pequeños.
  7. Coloca barandillas.
  8. Muestra los datos de posicionamiento en formato JSON.

A continuación, se detalla cada paso.

Cargando la captura de pantalla y los archivos JSON

Se usa el elemento cv::imread habitual para cargar capturas de pantalla. Probé varias bibliotecas para los archivos JSON, pero picojson parecía el más fácil de usar.

Conversión de imágenes y texto en "islas"

Compilación de las etapas

Lo anterior es una captura de pantalla de la sección Noticias de aid-dcc.com (haz clic para ver el tamaño real). Las imágenes y los elementos de texto deben convertirse en islas. Para aislar estas secciones, debemos borrar el color blanco de fondo, es decir, el color más frecuente en la captura de pantalla. A continuación, te mostramos cómo se ve una vez que lo hayas hecho:

Compilación de las etapas

Las secciones blancas son las islas potenciales.

El texto es demasiado fino y nítido, por lo que se le pondrá más grueso con cv::dilate, cv::GaussianBlur y cv::threshold. También falta el contenido de la imagen, así que rellenaremos esas áreas con blanco, según la salida de datos de la etiqueta img desde PhantomJS. La imagen resultante se ve así:

Compilación de las etapas

El texto ahora forma grupos adecuados y cada imagen es una isla apropiada.

Creación de puentes para conectar las islas

Una vez que las islas están listas, se conectan con puentes. Cada isla busca islas adyacentes a la izquierda, a la derecha, arriba y abajo, y luego conecta un puente con el punto más cercano de la isla más cercana, lo que da como resultado algo como lo siguiente:

Compilación de las etapas

Eliminar puentes innecesarios para crear un laberinto

Mantener todos los puentes haría que el escenario fuera demasiado fácil, por lo que algunos deben ser eliminados para crear un laberinto. Se elige una isla (p.ej., la de la esquina superior izquierda) como punto de partida, y se borran todos los puentes que se conectan a ella, excepto uno (seleccionados de forma aleatoria). Luego, se hace lo mismo para la siguiente isla conectada por el puente restante. Una vez que el camino llega a un callejón sin salida o conduce a una isla ya visitada, retrocede hasta un punto que permite acceder a una nueva isla. El laberinto estará completo una vez que todas las islas se procesen de esta manera.

Compilación de las etapas

Colocar elementos grandes

Se colocan uno o más elementos grandes en cada isla según sus dimensiones, y se eligen los puntos más alejados de los bordes de las islas. Aunque no son muy claros, estos puntos se muestran en rojo a continuación:

Compilación de las etapas

De todos estos puntos posibles, el que está en la parte superior izquierda se establece como el punto de partida (círculo rojo), el que está en la parte inferior derecha se establece como el objetivo (círculo verde) y se eligen un máximo de seis del resto para la colocación del elemento grande (círculo púrpura).

Colocación de elementos pequeños

Compilación de las etapas

Se coloca una cantidad adecuada de elementos pequeños a lo largo de líneas a distancias determinadas desde los bordes de la isla. La imagen de arriba (que no es de aid-dcc.com) muestra las líneas de ubicación proyectadas en gris, desplazadas y colocadas en intervalos regulares desde los bordes de la isla. Los puntos rojos indican dónde se colocan los elementos pequeños. Como esta imagen es de una versión de desarrollo en medio del desarrollo, los elementos se presentan en líneas rectas, pero la versión final los dispersa un poco más irregularmente a ambos lados de las líneas grises.

Colocación de barandillas

Las barandillas protectoras se colocan básicamente a lo largo de los límites exteriores de las islas, pero deben cortarse en los puentes para permitir el acceso. La biblioteca de geometría de Boost demostró ser útil para esto, ya que simplificó los cálculos geométricos, como determinar dónde se cruzan los datos de límite de las islas con las líneas a ambos lados de un puente.

Compilación de las etapas

Las líneas verdes que delinean las islas son las barandillas. Puede ser difícil verlo en esta imagen, pero no hay líneas verdes donde se encuentren los puentes. Esta es la imagen final que se usa para la depuración, en la que se incluyen todos los objetos que deben escribirse en JSON. Los puntos azules claros son elementos pequeños y los puntos grises son puntos de reinicio. Cuando la pelota cae al océano, el juego se reanuda desde el punto de reinicio más cercano. Los puntos de reinicio están dispuestos de manera más o menos igual que los elementos pequeños, a intervalos regulares a una distancia determinada desde el borde de la isla.

Genera datos de posicionamiento en formato JSON

También usé picojson para la salida. Escribe los datos en la salida estándar, que luego recibe el llamador (Node.js).

Cómo crear un programa de C++ en una Mac para que se ejecute en Linux

El juego se desarrolló en una Mac y se implementó en Linux, pero dado que OpenCV y Boost existían para ambos sistemas operativos, el desarrollo en sí no fue difícil una vez que se estableció el entorno de compilación. Usé las herramientas de línea de comandos en Xcode para depurar la compilación en Mac y luego creé un archivo de configuración con automake/autoconf para que la compilación pudiera compilarse en Linux. Luego, simplemente tuve que usar "configure && make" en Linux para crear el archivo ejecutable. Encontré algunos errores específicos de Linux debido a diferencias en la versión del compilador, pero pude resolverlos de manera relativamente fácil con gdb.

Conclusión

Un juego como este podría crearse con Flash o Unity, lo que brindaría numerosas ventajas. Sin embargo, esta versión no requiere complementos, y las funciones de diseño de HTML5 + CSS3 demostraron ser extremadamente potentes. Es muy importante contar con las herramientas adecuadas para cada tarea. En lo personal, me sorprendió lo bien que resultó el juego para uno totalmente creado en HTML5 y, aunque todavía falta en muchas áreas, espero ver cómo se desarrollará en el futuro.