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 quitar todo lo que sabíamos acerca de cómo crear una experiencia de usuario enriquecida en el navegador. Mientras Flash ofrecía una API única e integral para todos los aspectos del desarrollo de aplicaciones, desde el dibujo vectorial hasta la detección de aciertos de polígono y el análisis de XML, HTML5 ofrecía un conjunto de especificaciones con una compatibilidad de navegador variable. También nos preguntamos si HTML, un lenguaje específico de documento, y CSS, un lenguaje centrado en cuadros, eran adecuados para la compilación de un juego. ¿El juego se mostraría de manera uniforme en todos los navegadores como se mostraba en Flash y se vería y comportaría de la misma manera? Para Wordico, la respuesta era sí.

¿Cuál es tu vector, Victor?

Desarrollamos la versión original de Wordico únicamente utilizando gráficos vectoriales: líneas, curvas, rellenos y gradientes. El resultado fue altamente compacto y escalable infinitamente:

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

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

Espacio de tres letras en Flash.
Un espacio de tres letras en Flash.

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

Un objeto PNG que muestra los nueve espacios.
Un objeto PNG que muestra los nueve espacios.

Para crear un tablero de juegos de 15x15 desde espacios individuales, iteramos sobre una notación de cadenas de 225 caracteres en la que cada espacio está representado por un carácter diferente (por ejemplo, "t" para la letra triple y "T" para la palabra triple). Esta fue una operación sencilla en Flash; simplemente marcamos los 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 juegos un cuadrado a la vez. El primer paso es cargar el objeto image. Una vez cargado, 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 tiene una sombra paralela de CSS:

En HTML5, el tablero de juegos es un único elemento lienzo.
En HTML5, el tablero de juegos es un único elemento lienzo.

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

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

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

El mosaico HTML está compuesto por tres imágenes.
El mosaico HTML está compuesto por tres imágenes.

Ahora tenemos 100 lienzos (uno para cada tarjeta) más un lienzo para el tablero de juegos. Esta es la marca de un mosaico "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 el mosaico (sombra, opacidad y escalamiento) y cuando el mosaico está sobre el bastidor (reflexión):

El mosaico arrastrado es un poco más grande, ligeramente transparente y tiene una sombra paralela.
El mosaico arrastrado es un poco más grande, ligeramente transparente y tiene una sombra paralela.

Usar imágenes de trama tiene algunas ventajas obvias. Primero, el resultado es preciso en píxeles. Segundo, el navegador puede almacenar estas imágenes en caché. Tercero, con un poco de trabajo extra, podemos intercambiar las imágenes para crear nuevos diseños de mosaicos (por ejemplo, una tarjeta metálica) y este trabajo se puede realizar en Photoshop, en lugar de en Flash.

¿La desventaja? Cuando usamos imágenes, renunciamos al acceso programático a los campos de texto. En Flash, cambiar el color u otras propiedades del tipo fue una operación simple; en HTML5, estas propiedades se incorporan a las imágenes. (Probamos el texto HTML, pero requería mucho lenguaje de marcado y CSS adicional. También probamos texto de lienzo, pero los resultados no fueron uniformes en todos los navegadores).

Lógica parcial

Queríamos hacer un uso completo de la ventana del navegador en cualquier tamaño y evitar el desplazamiento. Esta fue una operación relativamente simple en Flash, ya que todo el juego se dibujaba en vectores y podía aumentar o disminuir la escala sin perder fidelidad. Pero era más complicado en HTML. Intentamos usar el escalamiento de CSS, pero el resultado es un lienzo desenfocado:

Escalamiento de CSS (izquierda) en comparación con el rediseño (derecha)
Escala de CSS (izquierda) en comparación con volver a dibujar (derecha).

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

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

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

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

Terminamos con imágenes nítidas y diseños agradables en cualquier tamaño de pantalla:

El tablero de juegos ocupa el espacio vertical; otros elementos de página fluyen a su alrededor.
El tablero de juegos ocupa el espacio vertical; otros elementos de la página fluyen a su alrededor.

Ve al grano

Como cada tarjeta está absolutamente posicionada y debe alinearse con precisión con el tablero de juegos 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 de la página, mientras que Point describe una coordenada x,y relativa a 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 un mosaico cruza el bastidor) o si un área rectangular (como un espacio de dos letras) contiene un punto arbitrario (como el punto central de un mosaico). Esta es la implementación de los límites:

// 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 arrastrar y soltar y de animación. Por ejemplo, usamos Bounds.intersects() para determinar si una tarjeta se superpone con un espacio en el tablero de juegos. 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 aceleració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. Ten en cuenta 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 hacia abajo.
Este diseño no tiene dimensiones fijas: las miniaturas fluyen de izquierda a derecha y de arriba hacia abajo.

También puedes considerar el panel del 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, cálculos matemáticos para calcular la posición de desplazamiento y muchos otros códigos para unirlos.

El panel del chat en Flash era bastante pero complejo.
El panel del chat en Flash era bastante pero complejo.

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

El modelo de caja de CSS en funcionamiento.
El modelo de caja de CSS en funcionamiento.

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

¿Puedes oírme ahora?

Tuvimos problemas con la etiqueta <audio>. Simplemente no podía reproducir efectos de sonido cortos de forma reiterada en algunos navegadores. Probamos dos soluciones alternativas. Primero, rellenamos los archivos de sonido con aire muerto para hacerlos más largos. Luego, intentamos alternar la reproducción en varios canales de audio. Ninguna de las técnicas era completamente efectiva ni elegante.

Finalmente, decidimos implementar nuestro propio reproductor de audio Flash y utilizar 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, crearemos 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 funciona solo para archivos MP3; nunca nos molestamos en admitir OGG. Esperamos que en el futuro cercano la industria elija un formato único.

Posición de la encuesta

En HTML5, usamos la misma técnica que en Flash para actualizar el estado del juego: cada 10 segundos, el cliente solicita actualizaciones al servidor. Si el estado del juego cambió desde la última encuesta, el cliente recibe y maneja los cambios; de lo contrario, no sucede nada. Esta técnica de sondeo tradicional es aceptable, pero no del todo elegante. Sin embargo, nos gustaría cambiar a los sondeos largos o a WebSockets a medida que el juego se desarrolle y los usuarios esperen interacción en tiempo real a través de la red. En particular, los WebSockets presentarían muchas oportunidades para mejorar el juego.

¡Qué herramienta!

Utilizamos 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.). El JavaScript se compila del código fuente de Java. Por ejemplo, la función Punto 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 IU tienen archivos de plantilla correspondientes en los que los elementos de 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>

Todos los detalles están fuera del alcance de este artículo, pero te recomendamos que consultes GWT para tu próximo proyecto de HTML5.

¿Por qué usar Java? Primero, para una escritura estricta. Si bien la escritura 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. Segundo, para las capacidades de refactorización. Considera cómo cambiarías una firma de método JavaScript en miles de líneas de código, lo que no es fácil. Pero con un buen IDE de Java, es muy sencillo. Por último, con fines de prueba. La escritura de pruebas de unidades para las clases de Java supera la tradicional técnica de "guardar y actualizar".

Resumen

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