Caso de éxito: Conversión de Wordico de Flash a HTML5

Introducción

Cuando convertimos nuestro juego de crucigramas Wordico de Flash a HTML5, nuestra primera tarea fue olvidar todo lo que sabíamos sobre la creación de una experiencia del usuario enriquecida en el navegador. Mientras que Flash ofrecía una API única y completa para todos los aspectos del desarrollo de aplicaciones, desde el dibujo vectorial hasta la detección de hits de polígonos y el análisis de XML, HTML5 ofrecía un conjunto de especificaciones con compatibilidad variable con los navegadores. También nos preguntamos si HTML, un lenguaje específico del documento, y CSS, un lenguaje centrado en los cuadros, eran adecuados para crear un juego. ¿El juego se mostrará de forma uniforme en todos los navegadores, como lo hacía en Flash, y se verá y se comportará tan bien? En el caso de Wordico, la respuesta fue sí.

¿Cuál es tu vector, Víctor?

Desarrollamos la versión original de Wordico solo con gráficos vectoriales: líneas, curvas, rellenos y gradientes. El resultado fue muy compacto y escalable de forma infinita:

Esquema de página de Wordico
En Flash, cada objeto de visualización estaba hecho de formas vectoriales.

También aprovechamos la línea de tiempo de Flash para crear objetos con varios estados. Por ejemplo, usamos nueve fotogramas clave con nombre para el objeto Space:

Es un espacio de tres letras en Flash.
Es un espacio de tres letras en Flash.

Sin embargo, en HTML5, usamos un sprite de mapa de bits:

Un sprite de PNG que muestra los nueve espacios.
Un sprite PNG que muestra los nueve espacios.

Para crear el tablero de juego de 15 × 15 a partir de espacios individuales, iteramos sobre una notación de cadena de 225 caracteres en la que cada espacio está representado por un carácter diferente (como "t" para una letra triple y "T" para una palabra triple). Esta fue una operación sencilla en Flash. Simplemente, marcamos espacios y los organizamos en una cuadrícula:

var spaces:Array = new Array();

for (var i:int = 0; i < 225; i++) {
  var space:Space = new Space(i, layout.charAt(i));
  ...
  spaces.push(addChild(space));
}

LayoutUtil.grid(spaces, 15);

En HTML5, es un poco más complicado. Usamos el elemento <canvas>, una superficie de dibujo de mapa de bits, para pintar el tablero de juego un cuadrado a la vez. El primer paso es cargar el sprite de imagen. Una vez que se carga, iteramos a través de la notación de diseño y dibujamos una parte diferente de la imagen con cada iteración:

var x = 0;  // x coordinate
var y = 0;  // y coordinate
var w = 35; // width and height of a space

for (var i = 0; i < 225; i++) {
  if (i && i % 15 == 0) {
    x = 0;
    y += w;
  }

  var imageX = "_dDFtTqQxm".indexOf(layout.charAt(i)) * 70;

  canvas.drawImage("spaces.png", imageX, 0, 70, 70, x, y, w, w);

  x += w;
}

Este es el resultado en el navegador web. Ten en cuenta que el lienzo en sí tiene una sombra paralela de CSS:

En HTML5, el tablero de juego es un solo elemento de lienzo.
En HTML5, el tablero de juego es un solo elemento de lienzo.

La conversión del objeto de tarjeta fue un ejercicio similar. En Flash, usamos campos de texto y formas vectoriales:

La tarjeta de Flash era una combinación de campos de texto y formas vectoriales.
La tarjeta Flash era una combinación de campos de texto y formas vectoriales.

En HTML5, combinamos tres sprites de imagen en un solo elemento <canvas> durante el tiempo de ejecución:

La tarjeta HTML es un compuesto de tres imágenes.
La tarjeta HTML es un compuesto de tres imágenes.

Ahora tenemos 100 lienzos (uno para cada tarjeta) más un lienzo para el tablero de juego. Este es el marcado de una tarjeta "H":

<canvas width="35" height="35" class="tile tile-racked" title="H-2"/>

Este es el CSS correspondiente:

.tile {
  width: 35px;
  height: 35px;
  position: absolute;
  cursor: pointer;
  z-index: 1000;
}

.tile-drag {
  -moz-box-shadow: 1px 1px 7px rgba(0,0,0,0.8);
  -webkit-box-shadow: 1px 1px 7px rgba(0,0,0,0.8);
  -moz-transform: scale(1.10);
  -webkit-transform: scale(1.10);
  -webkit-box-reflect: 0px;
  opacity: 0.85;
}

.tile-locked {
  cursor: default;
}

.tile-racked {
  -webkit-box-reflect: below 0px -webkit-gradient(linear, 0% 0%, 0% 100%,  
    from(transparent), color-stop(0.70, transparent), to(white));
}

Aplicamos efectos CSS3 cuando se arrastra la tarjeta (sombra, opacidad y escalamiento) y cuando esta se encuentra en el bastidor (reflexión):

La tarjeta arrastrada es un poco más grande, un poco más transparente y tiene una sombra.
La tarjeta arrastrada es un poco más grande, un poco más transparente y tiene una sombra.

El uso de imágenes de trama tiene algunas ventajas evidentes. En primer lugar, el resultado es preciso hasta el píxel. En segundo lugar, el navegador puede almacenar en caché estas imágenes. En tercer lugar, con un poco de trabajo adicional, podemos cambiar las imágenes para crear nuevos diseños de tarjetas, como una tarjeta de metal, y este trabajo de diseño se puede realizar en Photoshop en lugar de en Flash.

¿Cuál es la desventaja? Cuando usamos imágenes, renunciamos al acceso programático a los campos de texto. En Flash, era una operación simple cambiar el color o cualquier otra propiedad del tipo. En HTML5, estas propiedades se integran en las imágenes. (Probamos el texto HTML, pero requirió mucho lenguaje de marcado y CSS adicionales. También probamos el texto del lienzo, pero los resultados no fueron coherentes en todos los navegadores).

Lógica difusa

Queríamos aprovechar al máximo la ventana del navegador en cualquier tamaño y evitar el desplazamiento. Esta era una operación relativamente simple en Flash, ya que todo el juego se dibujaba en vectores y se podía ampliar o reducir sin perder fidelidad. Pero era más complicado en HTML. Intentamos usar la escala de CSS, pero terminamos con un lienzo desenfocado:

Escalamiento de CSS (izquierda) en comparación con la nueva creación (derecha).
A escalamiento de CSS (izquierda) en comparación con la nueva creación (derecha).

Nuestra solución es volver a dibujar el tablero de juego, el bastidor y las tarjetas cada vez que el usuario cambia el tamaño del navegador:

window.onresize = function (evt) {
...
gameboard.setConstraints(boardWidth, boardWidth);

...
rack.setConstraints(rackWidth, rackHeight);

...
tileManager.resizeTiles(tileSize);
});

El resultado son imágenes nítidas y diseños agradables en cualquier tamaño de pantalla:

El tablero de juego ocupa el espacio vertical y los demás elementos de la página fluyen a su alrededor.
El tablero de juego ocupa todo el espacio vertical y los demás elementos de la página fluyen a su alrededor.

Ve al grano

Dado que cada tarjeta tiene una posición absoluta y debe alinearse con precisión con el tablero de juego y el bastidor, necesitamos un sistema de posicionamiento confiable. Usamos dos funciones, Bounds y Point, para ayudar a administrar la ubicación de los elementos en el espacio global (la página HTML). Bounds describe un área rectangular en la página, mientras que Point describe una coordenada x,y en relación con la esquina superior izquierda de la página (0,0), también conocida como punto de registro.

Con Bounds, podemos detectar la intersección de dos elementos rectangulares (como cuando una tarjeta cruza el bastidor) o si un área rectangular (como un espacio de dos letras) contiene un punto arbitrario (como el punto central de una tarjeta). Esta es la implementación de Bounds:

// bounds.js
function Bounds(element) {
var x = element.offsetLeft;
var y = element.offsetTop;
var w = element.offsetWidth;
var h = element.offsetHeight;

this.left = x;
this.right = x + w;
this.top = y;
this.bottom = y + h;
this.width = w;
this.height = h;
this.x = x;
this.y = y;
this.midx = x + (w / 2);
this.midy = y + (h / 2);
this.topleft = new Point(x, y);
this.topright = new Point(x + w, y);
this.bottomleft = new Point(x, y + h);
this.bottomright = new Point(x + w, y + h);
this.middle = new Point(x + (w / 2), y + (h / 2));
}

Bounds.prototype.contains = function (point) {
return point.x > this.left &amp;&amp;
point.x < this.right &amp;&amp;
point.y > this.top &amp;&amp;
point.y < this.bottom;
}

Bounds.prototype.intersects = function (bounds) {
return this.contains(bounds.topleft) ||
this.contains(bounds.topright) ||
this.contains(bounds.bottomleft) ||
this.contains(bounds.bottomright) ||
bounds.contains(this.topleft) ||
bounds.contains(this.topright) ||
bounds.contains(this.bottomleft) ||
bounds.contains(this.bottomright);
}

Bounds.prototype.toString = function () {
return [this.x, this.y, this.width, this.height].join(",");
}

Usamos Point para determinar la coordenada absoluta (esquina superior izquierda) de cualquier elemento de la página o de un evento del mouse. Point también contiene métodos para calcular la distancia y la dirección, que son necesarios para crear efectos de animación. Esta es la implementación de Point:

// point.js

function Point(x, y) {
this.x = x;
this.y = y;
}

Point.prototype.distance = function (point) {
var a = point.x - this.x;
var b = point.y - this.y;

return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

Point.prototype.distanceX = function (point) {
return Math.abs(this.x - point.x);
}

Point.prototype.distanceY = function (point) {
return Math.abs(this.y - point.y);
}

Point.prototype.interpolate = function (point, pct) {
var x = this.x + ((point.x - this.x) * pct);
var y = this.y + ((point.y - this.y) * pct);

return new Point(x, y);
}

Point.prototype.offset = function (x, y) {
return new Point(this.x + x, this.y + y);
}

Point.prototype.vector = function (point) {
return new Point(point.x - this.x, point.y - this.y);
}

Point.prototype.toString = function () {
return this.x + "," + this.y;
}

// static
Point.fromElement = function (element) {
return new Point(element.offsetLeft, element.offsetTop);
}

// static
Point.fromEvent = function (evt) {
return new Point(evt.x || evt.clientX, evt.y || evt.clientY);
}

Estas funciones forman la base de las capacidades de animación y arrastrar y soltar. Por ejemplo, usamos Bounds.intersects() para determinar si una tarjeta se superpone con un espacio en el tablero de juego, usamos Point.vector() para determinar la dirección de una tarjeta arrastrada y usamos Point.interpolate() en combinación con un temporizador para crear una interpolación de movimiento o un efecto de suavización.

Fluyo con la corriente

Si bien los diseños de tamaño fijo son más fáciles de producir en Flash, los diseños fluidos son mucho más fáciles de generar con HTML y el modelo de caja CSS. Considera la siguiente vista de cuadrícula, con su ancho y altura variables:

Este diseño no tiene dimensiones fijas: las miniaturas fluyen de izquierda a derecha y de arriba abajo.
Este diseño no tiene dimensiones fijas: las miniaturas fluyen de izquierda a derecha y de arriba abajo.

O considera el panel de chat. La versión de Flash requería varios controladores de eventos para responder a las acciones del mouse, una máscara para el área desplazable, matemáticas para calcular la posición de desplazamiento y mucho más código para unirlo todo.

El panel de chat en Flash era atractivo, pero complejo.
El panel de chat en Flash era atractivo, pero complejo.

En comparación, la versión HTML es solo un <div> con una altura fija y la propiedad de desbordamiento establecida como oculta. El desplazamiento no nos cuesta nada.

El modelo de caja de CSS en acción.
El modelo de caja de CSS en acción.

En casos como este (tareas de diseño ordinarias), HTML y CSS superan a Flash.

¿Me puedes escuchar ahora?

Tuvimos problemas con la etiqueta <audio>, ya que no era capaz de reproducir efectos de sonidos cortos de forma repetida en ciertos navegadores. Probamos dos soluciones alternativas. Primero, rellenamos los archivos de sonido con silencio para hacerlos más largos. Luego, intentamos alternar la reproducción en varios canales de audio. Ninguna de las técnicas fue completamente eficaz ni elegante.

Al final, decidimos crear nuestro propio reproductor de audio Flash y usar audio HTML5 como resguardo. Este es el código básico en Flash:

var sounds = new Array();

function playSound(path:String):void {
var sound:Sound = sounds[path];

if (sound == null) {
sound = new Sound();
sound.addEventListener(Event.COMPLETE, function (evt:Event) {
    sound.play();
});
sound.load(new URLRequest(path));
sounds[path] = sound;
}
else {
sound.play();
}
}

ExternalInterface.addCallback("playSound", playSound);

En JavaScript, intentamos detectar el reproductor Flash incorporado. Si eso falla, creamos un nodo <audio> para cada archivo de sonido:

function play(String soundId) {
var src = "/audio/" + soundId + ".mp3";

// Flash
try {
var swf = window["swfplayer"] || document["swfplayer"];
swf.playSound(src);
}
// or HTML5 audio
catch (e) {
var sound = document.getElementById(soundId);
if (sound == null || sound == undefined) {
    var sound = document.createElement("audio");
    sound.id = soundId;
    sound.src = src;
    document.body.appendChild(sound);
}
sound.play();
}
}

Ten en cuenta que esto solo funciona para archivos MP3. Nunca nos molestamos en admitir OGG. Esperamos que la industria se decida por un solo formato en un futuro cercano.

Posición de la encuesta

Usamos la misma técnica en HTML5 que en Flash para actualizar el estado del juego: cada 10 segundos, el cliente le solicita actualizaciones al servidor. Si el estado del juego cambió desde la última consulta, el cliente recibe y controla los cambios. De lo contrario, no sucede nada. Esta técnica de sondeo tradicional es aceptable, aunque no es muy elegante. Sin embargo, nos gustaría cambiar a polling de larga duración o WebSockets a medida que el juego madure y los usuarios esperen una interacción en tiempo real a través de la red. En particular, WebSockets presentaría muchas oportunidades para mejorar el juego.

¡Qué herramienta!

Usamos Google Web Toolkit (GWT) para desarrollar la interfaz de usuario del frontend y la lógica de control del backend (autenticación, validación, persistencia, etcétera). El código JavaScript se compila a partir del código fuente de Java. Por ejemplo, la función Point se adapta de Point.java:

package com.wordico.client.view.layout;

import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.event.dom.client.DomEvent;

public class Point {
public double x;
public double y;

public Point(double x, double y) {
this.x = x;
this.y = y;
}

public double distance(Point point) {
double a = point.x - this.x;
double b = point.y - this.y;

return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}
...
}

Algunas clases de la IU tienen archivos de plantillas correspondientes en los que los elementos de la página están "vinculados" a los miembros de la clase. Por ejemplo, ChatPanel.ui.xml corresponde a ChatPanel.java:

<!DOCTYPE ui:UiBinder SYSTEM "http://dl.google.com/gwt/DTD/xhtml.ent">

<ui:UiBinder
xmlns:ui="urn:ui:com.google.gwt.uibinder"
xmlns:g="urn:import:com.google.gwt.user.client.ui"
xmlns:w="urn:import:com.wordico.client.view.widget">

<g:HTMLPanel>
<div class="palette">
<g:ScrollPanel ui:field="messagesScroll">
    <g:FlowPanel ui:field="messagesFlow"></g:FlowPanel>
</g:ScrollPanel>
<g:TextBox ui:field="chatInput"></g:TextBox>
</div>
</g:HTMLPanel>

</ui:UiBinder>

Los detalles completos exceden el alcance de este artículo, pero te recomendamos que pruebes GWT para tu próximo proyecto HTML5.

¿Por qué usar Java? Primero, para la tipificación estricta. Si bien la tipificación dinámica es útil en JavaScript (por ejemplo, la capacidad de una matriz para contener valores de diferentes tipos), puede ser un dolor de cabeza en proyectos grandes y complejos. En segundo lugar, para las capacidades de refactorización. Piensa en cómo cambiarías la firma de un método de JavaScript en miles de líneas de código. No es fácil. Sin embargo, con un buen IDE de Java, es muy sencillo. Por último, con fines de prueba. Escribir pruebas de unidades para clases de Java supera la técnica tradicional de "guardar y actualizar".

Resumen

Excepto por nuestros problemas de audio, HTML5 superó nuestras expectativas. Wordico no solo se ve tan bien como en Flash, sino que también es tan fluido y responsivo. No podríamos haberlo hecho sin Canvas y CSS3. Nuestro próximo desafío: adaptar Wordico para el uso en dispositivos móviles.