Caso de éxito: Inside World Wide Maze

World Wide Maze es un juego en el que usas tu smartphone para navegar con una bola rodante por laberintos en 3D creados a partir de sitios web para tratar de llegar a los puntos de destino.

World Wide Maze

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

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

DeviceOrientation

El evento DeviceOrientation (ejemplo) se usa para recuperar datos de inclinación del smartphone. Cuando se usa 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 use. Por ejemplo, en iOS y Chrome, y en iOS y Safari, la devolución de llamada se invoca aproximadamente cada 1/20 de segundo, mientras que en Android 4 y Chrome se invoca aproximadamente cada 1/10 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 en radianes) (Obtén más información en HTML5Rocks). Sin embargo, los valores que se muestran también varían según la combinación de dispositivo y navegador que se use. Los rangos de los valores reales que se muestran se indican en la siguiente tabla:

Orientación del dispositivo.

Los valores de la parte superior destacados en azul son los que se definen en las especificaciones del W3C. Las que se destacan en verde coinciden con estas especificaciones, mientras que las que se destacan en rojo se desvían. Sorprendentemente, solo la combinación de Android y Firefox mostró valores que coincidían con las especificaciones. Sin embargo, cuando se trata de la implementación, tiene más sentido adaptarse a los valores que ocurren con frecuencia. Por lo tanto, World Wide Maze usa los valores que devuelve iOS de forma predeterminada y se ajusta a los dispositivos Android según corresponda.

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

Sin embargo, aún no es compatible con el Nexus 10. Aunque el Nexus 10 muestra el mismo rango de valores que otros dispositivos Android, hay un error que invierte los valores de beta y gamma. Este problema se está abordando por separado. (¿Quizás se establece de forma predeterminada la orientación horizontal?).

Como se demuestra, incluso si las APIs que involucran dispositivos físicos tienen especificaciones establecidas, no hay garantía de que los valores que se devuelven coincidan con esas especificaciones. Por lo tanto, es fundamental probarlos en todos los dispositivos potenciales. Esto también significa que se pueden ingresar valores inesperados, lo que requiere la creación de soluciones alternativas. World Wide Maze les solicita a los jugadores que lo usan por primera vez que calibren sus dispositivos como paso 1 del instructivo, pero no se calibrará en la posición cero correctamente si recibe valores de inclinación inesperados. Por lo tanto, tiene un límite de tiempo interno y le solicita al jugador que cambie a los controles del teclado si no puede calibrarse dentro de ese límite.

WebSocket

En World Wide Maze, tu smartphone y tu PC se conectan a través de WebSocket. Más precisamente, se conectan a través de un servidor de retransmisión, es decir, del smartphone al servidor y a la PC. Esto se debe a que WebSocket no puede conectar navegadores directamente entre sí. (El uso de canales de datos de WebRTC permite la conectividad entre pares y elimina la necesidad de un servidor de retransmisión, pero, en el momento de la implementación, este método solo se podía usar con Chrome Canary y Firefox Nightly).

Elegí implementarlo 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 de la conexión o se desconecte. La 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 conecta al servidor.
  2. El servidor le asigna a tu PC un número generado de forma aleatoria y recuerda la combinación de número y PC.
  3. Desde 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, significa que tu dispositivo móvil está vinculado a esa PC.
  5. Si no hay una PC designada, se produce un error.
  6. Cuando los datos llegan desde tu dispositivo móvil, se envían a la PC con la que está vinculado y viceversa.

También puedes establecer 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 están abiertas 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 agrega a la URL de una página con history.replaceState.

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

Si la sincronización de pestañas está habilitada, la URL se sincronizará después de unos segundos y se podrá 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 de forma manual o escanear códigos QR con una cámara.

Latencia

Dado que el servidor de retransmisión se encuentra en EE.UU., acceder a él desde Japón genera una demora 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 que se usó durante el desarrollo, pero insertar algo como un filtro de paso bajo (usé EMA) mejoró esto a niveles discretos. (En la práctica, también se necesitaba un filtro de paso bajo para fines de presentación; los valores que se mostraban del sensor de inclinación incluían una cantidad considerable de ruido, y aplicar esos valores a la pantalla tal como estaba generaba mucho movimiento). Esto no funcionó con los saltos, que eran claramente lentos, pero no se podía hacer nada para resolver este problema.

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 (lo que minimiza la latencia). Sin embargo, terminé usando Google Compute Engine (GCE), que solo existía en EE.UU. en ese momento, por lo que no fue posible.

El problema del algoritmo de Nagle

Por lo general, el algoritmo de Nagle se incorpora a los sistemas operativos para lograr una comunicación eficiente a través del almacenamiento en búfer a nivel del TCP, pero descubrí que no podía enviar datos en tiempo real mientras este algoritmo estaba habilitado. (Específicamente, cuando se combina con el acuse de recibo retrasado de TCP). Incluso sin un ACK retrasado, se produce el mismo problema si ACK se retrasa en cierto grado debido a factores como la ubicación del servidor 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 el WebSocket de WebKit que se usa en Chrome para iOS, 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 haciendo que el servidor envíe datos en intervalos cortos (cada 50 ms aproximadamente). Creo que recibir ACK en intervalos cortos engaña al algoritmo de Nagle para que piense que está bien enviar datos.

Algoritmo de Nagle 1

En el gráfico anterior, se muestran los intervalos de datos reales recibidos. Indica los intervalos de tiempo entre paquetes. El verde representa los intervalos de salida y el rojo representa los intervalos de entrada. El mínimo es de 54 ms, el máximo es de 158 ms y el medio es de alrededor de 100 ms. Aquí, usé un iPhone con un servidor de retransmisión ubicado en Japón. La salida y la entrada son de alrededor de 100 ms, y la operación es fluida.

Algoritmo de Nagle 2

En cambio, este gráfico muestra los resultados de usar el servidor en EE.UU. Mientras que los intervalos de salida verdes se mantienen estables en 100 ms, los intervalos de entrada fluctúan entre valores 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, este gráfico muestra los resultados de evitar la latencia haciendo que el servidor envíe datos de marcadores de posición. Si bien no tiene un rendimiento tan bueno como el servidor japonés, está claro que los intervalos de entrada permanecen relativamente estables en alrededor de 100 ms.

¿Un error?

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

Escalamiento de servidores de retransmisión

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

Física

El movimiento de la pelota en el juego (rodar cuesta abajo, chocar con el suelo, chocar con paredes, recolectar elementos, etc.) se realiza con un simulador de física en 3D. Usé Ammo.js, un puerto del motor de física Bullet muy usado en JavaScript con Emscripten, junto con Physijs para usarlo como "trabajador web".

Web Workers

Web Workers es una API para ejecutar JavaScript en subprocesos independientes. JavaScript iniciado como un trabajador web se ejecuta como un subproceso independiente del que lo llamó originalmente, por lo que se pueden realizar tareas pesadas y, al mismo tiempo, mantener la capacidad de respuesta de la página. Physijs usa Web Workers de manera eficiente para ayudar al motor de física en 3D, que suele ser intensivo, a funcionar sin problemas. World Wide Maze controla el motor de física y la renderización de imágenes 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 una carga de renderización pesada de WebGL, el motor de física mantendrá más o menos 60 fps y no impedirá los controles del juego.

FPS

En esta imagen, se muestran las velocidades de fotogramas resultantes en una 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 fotogramas de renderización de imágenes no alcanzó los 60 fps esperados. Sin embargo, como el motor de física logró la velocidad de fotogramas esperada, el juego no es tan diferente del rendimiento en una máquina de alta especificación.

Dado que los subprocesos con Web Workers activos no tienen objetos de 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 mucho el proceso de depuración.

Service workers

Las versiones recientes de Chrome te permiten establecer puntos de interrupción cuando inicias Web Workers, lo que también es útil para la depuración. Puedes encontrar esta información en el panel "Trabajadores" de Herramientas para desarrolladores.

Rendimiento

Las etapas con recuentos de polígonos altos a veces superan los 100,000 polígonos, pero el rendimiento no se vio afectado, incluso cuando se generaron por completo como Physijs.ConcaveMesh (btBvhTriangleMeshShape en Bullet).

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 de Physijs original.

Objetos fantasma

Los objetos que tienen detección de colisiones, pero no tienen un impacto en la colisión y, por lo tanto, no tienen efecto en otros objetos, se denominan "objetos fantasma" en Bullet. Aunque Physijs no admite oficialmente objetos fantasma, es posible crearlos allí manipulando las marcas después de generar un Physijs.Mesh. World Wide Maze usa objetos fantasma para la detección de colisiones de elementos y puntos de destino.

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 el foro de Bullet, en Stack Overflow o en la documentación de Bullet. Dado que Physijs es un wrapper para Ammo.js y Ammo.js es básicamente idéntico a Bullet, 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 la versión 17 a la 18 cambió la forma en que los trabajadores web intercambiaban datos, y Physijs dejó de funcionar como resultado. 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. Escribo este artículo con la esperanza de proporcionar algún tipo de comentario.

asm.js

Aunque esto no afecta directamente a World Wide Maze, Ammo.js ya admite asm.js, que Mozilla anunció recientemente (no es de extrañar, ya que asm.js se creó básicamente para acelerar el JavaScript que genera Emscripten, 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 fue 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, portarlas a JavaScript con Emscripten?

WebGL

Para la implementación de WebGL, usé la biblioteca más desarrollada, three.js (r53). Aunque la revisión 57 ya se había lanzado en las últimas etapas del desarrollo, se habían realizado cambios importantes en la API, por lo que me quedé con la revisión original para el lanzamiento.

Efecto de brillo

El efecto de brillo que se agrega al núcleo de la bola y a los elementos se implementa con una versión simple del llamado "MGF del método Kawase". Sin embargo, mientras que el método Kawase hace que todas las áreas brillantes brillen, World Wide Maze crea destinos de renderización independientes para las áreas que deben brillar. Esto se debe a que se debe usar una captura de pantalla del sitio web para las texturas de escenario, y si solo se extraen todas las áreas brillantes, todo el sitio web brillará 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 complicado bastante.

Brillo

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. En la parte inferior derecha, se muestra el segundo pase, en el que se redujo el tamaño de la imagen en un 50% y, luego, se aplicó un desenfoque. En la parte superior derecha, se muestra el tercer pase, en el que la imagen se redujo nuevamente en un 50% y, luego, se desenfocó. Luego, se superpusieron las tres para crear la imagen compuesta final que se muestra en la parte inferior izquierda. Para el desenfoque, usé VerticalBlurShader y HorizontalBlurShader, incluidos en three.js, por lo que aún hay margen para optimizarlo más.

Bola reflectante

El reflejo en la pelota se basa en un ejemplo de three.js. Todas las direcciones se renderizan desde 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 intensiva, se actualizan cada tres fotogramas. El resultado no es tan fluido como actualizar cada fotograma, pero la diferencia es prácticamente imperceptible, a menos que se señale.

Sombreador, sombreador, sombreador…

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

Bolas de sombreador

Lo anterior proviene de las pruebas del efecto de malla que se usa cuando aparece la pelota. El de la izquierda es el que se usa en el juego y está compuesto por 320 polígonos. El del centro usa alrededor de 5,000 polígonos y el de la derecha usa alrededor de 300,000. 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 dispersos por el escenario están integrados en una sola malla, y el movimiento individual depende de los sombreadores que mueven 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 muestran alrededor de 5,000 objetos, compuestos por aproximadamente 20,000 polígonos. El rendimiento no se vio afectado en absoluto.

poly2tri

Las etapas se forman en función de la información del esquema que se recibe del servidor y, luego, JavaScript las convierte en polígonos. La triangulación, una parte clave de este proceso, se implementa de forma deficiente en three.js y, por lo general, falla. Por lo tanto, decidí integrar una biblioteca de triangulación diferente llamada poly2tri. Al parecer, three.js intentó hacer lo mismo en el pasado, así que logré que funcionara simplemente con la eliminación de parte de él. Como resultado, los errores disminuyeron significativamente, lo que permitió que se pudieran jugar muchas más etapas. El error ocasional persiste y, por alguna razón, poly2tri controla los errores emitiendo alertas, por lo que lo modifiqué para que arroje excepciones.

poly2tri

En lo anterior, se muestra cómo se triangula el contorno azul y se generan polígonos rojos.

Filtrado anisotrópico

Dado que el mapeo de MIP isotrópico estándar reduce el tamaño de las imágenes en los ejes horizontal y vertical, ver polígonos desde ángulos oblicuos hace que las texturas en el extremo lejano de las etapas de World Wide Maze se vean como texturas de baja resolución alargadas horizontalmente. La imagen de la esquina superior derecha de esta página de Wikipedia muestra un buen ejemplo de esto. En la práctica, se requiere más resolución horizontal, que WebGL (OpenGL) resuelve con un método llamado filtrado anisotrópico. En three.js, establecer 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 GPUs.

Optimizar

Como también se menciona en este artículo de 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, los puentes y los rieles de protección del juego eran objetos separados. Esto, a veces, generaba más de 2,000 llamadas de dibujo, lo que hacía que las etapas complejas fueran difíciles de manejar. Sin embargo, una vez que empaqueté los mismos tipos de objetos en una sola malla, las llamadas de dibujo disminuyeron a unos cincuenta, lo que mejoró el rendimiento de manera significativa.

Usé la función de seguimiento de Chrome para realizar más optimizaciones. Los generadores de perfiles incluidos en las herramientas para desarrolladores de Chrome pueden determinar los tiempos de procesamiento generales de los métodos hasta cierto punto, pero el seguimiento puede indicarte con precisión cuánto tiempo tarda cada parte, hasta 1/1000 de segundo. Consulta este artículo para obtener detalles sobre cómo usar el seguimiento.

Optimización

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

El proceso de seguimiento en sí ocupa recursos, por lo que insertar console.time de forma excesiva puede causar una desviación significativa del rendimiento real, lo que dificulta identificar las áreas que se deben optimizar.

Ajustador de rendimiento

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

  1. Si la velocidad de fotogramas cae por debajo de 45 fps, los mapas de entorno dejan de actualizarse.
  2. Si sigue por debajo de 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 el FXAA (antialiasing).
  4. Si sigue por debajo de 30 fps, se eliminan los efectos de brillo.

Fuga de memoria

Eliminar objetos de forma ordenada es un poco complicado con three.js. Pero dejarlos solos obviamente generaría fugas de memoria, así que ideé el siguiente método. @renderer hace referencia a THREE.WebGLRenderer. (La revisión más reciente de three.js usa un método de desasignación ligeramente diferente, 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

En lo personal, creo que lo mejor de la app de WebGL es la capacidad de diseñar el diseño de la página en HTML. Crear interfaces 2D, como pantallas de puntuación o texto, en Flash o openFrameworks (OpenGL) es un poco complicado. Flash, al menos, tiene un IDE, pero openFrameworks es difícil si no estás acostumbrado (usar algo como Cocos2D puede facilitarlo). Por otro lado, el HTML permite un control preciso de todos los aspectos del diseño del frontend con CSS, al igual que cuando se crean sitios web. Si bien los efectos complejos, como las partículas que se condensan en un logotipo, son imposibles, algunos efectos en 3D dentro de las capacidades de las transformaciones de CSS son posibles. Los efectos de texto "GOAL" y "TIME IS UP" de World Wide Maze se animan con escala en la transición de CSS (implementada con Transit). (Obviamente, las gradaciones de fondo usan WebGL).

Cada página del juego (el título, RESULT, RANKING, 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 inconveniente fue que no se podían configurar los eventos del mouse y el teclado antes de agregarlos, por lo que no funcionó intentar el.click (e) -> console.log(e) antes de agregarlos.

Internacionalización (i18n)

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

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

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

RequireJS

Elegí RequireJS como mi sistema de módulos de JavaScript. Las 10,000 líneas de código fuente del juego se dividen en alrededor de 60 clases (= archivos coffee) 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 funcionar, hoge.js debe cargarse antes que moge.js y, como “hoge” se designa como el primer argumento de “define”, siempre se carga primero hoge.js (se vuelve a llamar una vez que se termina de cargar). Este mecanismo se denomina AMD, y se puede usar cualquier biblioteca de terceros para el mismo tipo de devolución de llamada, siempre y cuando admita AMD. Incluso aquellos que no lo hacen (p.ej., three.js) tendrán un rendimiento similar, siempre y cuando las dependencias se especifiquen con anticipación.

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

r.js

RequireJS incluye un optimizador llamado r.js. Esto une el archivo JS principal con todos los archivos JS dependientes en uno y, luego, lo reduce con UglifyJS (o Closure Compiler). Esto reduce la cantidad de archivos y la cantidad total de datos que el navegador debe cargar. El tamaño total del archivo JavaScript de World Wide Maze es de alrededor de 2 MB y se puede reducir a aproximadamente 1 MB con la optimización de r.js. Si el juego se pudiera distribuir con 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, actualmente, el juego se distribuye sin comprimir como 1 MB de texto sin formato).

Creador de etapas

Los datos de la etapa se generan de la siguiente manera, y se realizan por completo en el servidor de GCE en EE.UU.:

  1. La URL del sitio web que se convertirá en un escenario se envía a través de WebSocket.
  2. PhantomJS toma una captura de pantalla, y las posiciones de las etiquetas div y img se recuperan y se muestran en formato JSON.
  3. En función de la captura de pantalla del paso 2 y los datos de posicionamiento de los elementos HTML, un programa personalizado de C++ (OpenCV, Boost) borra las áreas innecesarias, genera islas, las conecta con puentes, calcula las posiciones de los rieles de protección y los elementos, establece el punto de destino, etc. Los resultados se muestran 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 se puede usar en pruebas automatizadas o para capturar capturas de pantalla del servidor. Su motor de navegador es WebKit, el mismo que usan Chrome y Safari, por lo que su diseño y los resultados de la ejecución de JavaScript también 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 deseas que se ejecuten. Capturar capturas de pantalla es muy fácil, como se muestra en este ejemplo. Estaba trabajando en un servidor Linux (CentOS), por lo que necesitaba instalar fuentes para mostrar japonés (M+ FONTS). Incluso así, la renderización de fuentes se controla de manera diferente que en Windows o Mac OS, por lo que la misma fuente puede verse diferente en otras máquinas (aunque la diferencia es mínima).

La recuperación de las posiciones de las etiquetas img y div se controla básicamente de la misma manera que en las páginas estándar. jQuery también se puede usar sin problemas.

stage_builder

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

  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 objetos pequeños.
  7. Coloca barandas.
  8. Genera datos de posicionamiento en formato JSON.

A continuación, se detalla cada paso.

Carga la captura de pantalla y los archivos JSON

El cv::imread habitual se usa para cargar capturas de pantalla. Probé varias bibliotecas para los archivos JSON, pero picojson fue la más fácil de usar.

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

Compilación de etapa

La imagen anterior es una captura de pantalla de la sección de 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 de fondo blanco, en otras palabras, el color más predominante en la captura de pantalla. Así se verá una vez que hayas terminado:

Compilación de etapa

Las secciones blancas son las islas potenciales.

El texto es demasiado fino y nítido, por lo que lo engrosaremos con cv::dilate, cv::GaussianBlur y cv::threshold. También falta el contenido de la imagen, por lo que rellenaremos esas áreas de color blanco, según los datos de la etiqueta img que genera PhantomJS. La imagen resultante se ve de la siguiente manera:

Compilación de etapa

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

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 al punto más cercano de la isla más cercana, lo que genera algo como lo siguiente:

Compilación de etapa

Eliminar los puentes innecesarios para crear un laberinto

Si mantenemos todos los puentes, la navegación por el escenario sería demasiado fácil, por lo que algunos deben eliminarse para crear un laberinto. Se elige una isla (p.ej., la que está en la parte superior izquierda) como punto de partida y se borran todos los puentes excepto uno (seleccionado al azar) que se conecta a esa isla. Luego, se hace lo mismo con la siguiente isla conectada por el puente restante. Una vez que la ruta llega a un callejón sin salida o regresa a una isla que ya se visitó, retrocede hasta un punto que permite el acceso a una isla nueva. El laberinto se completa una vez que se procesan todas las islas de esta manera.

Compilación de etapa

Cómo 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 etapa

De todos estos puntos posibles, el de la parte superior izquierda se establece como el punto de partida (círculo rojo), el de la parte inferior derecha se establece como el objetivo (círculo verde) y se elige un máximo de seis del resto para la posición de elementos grandes (círculo púrpura).

Colocación de objetos pequeños

Compilación de etapa

Se colocan cantidades adecuadas de elementos pequeños a lo largo de líneas a distancias establecidas de los bordes de la isla. En la imagen anterior (no de aid-dcc.com), se muestran las líneas de colocación proyectadas en gris, desplazadas y ubicadas a 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 en desarrollo, los elementos se organizan en líneas rectas, pero la versión final dispersa los elementos de forma un poco más irregular a ambos lados de las líneas grises.

Colocación de guías

Los rieles de protección 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 resultó útil para esto, ya que simplificó los cálculos geométricos, como determinar dónde se cruzan los datos de límite de la isla con las líneas a ambos lados de un puente.

Compilación de etapa

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

Cómo generar datos de posicionamiento en formato JSON

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

Cómo crear un programa C++ en una Mac para ejecutarlo en Linux

El juego se desarrolló en una Mac y se implementó en Linux, pero como 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 se pudiera compilar en Linux. Luego, solo tuve que usar "configure && make" en Linux para crear el archivo ejecutable. Encontré algunos errores específicos de Linux debido a las diferencias en las versiones del compilador, pero pude resolverlos con relativa facilidad con gdb.

Conclusión

Un juego como este se podría crear 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 muy potentes. Sin duda, es importante tener las herramientas adecuadas para cada tarea. Personalmente, me sorprendió lo bien que resultó el juego para uno hecho completamente en HTML5 y, aunque aún le falta mucho en muchas áreas, espero ver cómo se desarrolla en el futuro.