Estudo de caso - Conversão do Wordico de Flash para HTML5

Introdução

Quando convertemos nosso jogo de palavras cruzadas Wordico do Flash para HTML5, nossa primeira tarefa foi esquecer tudo o que sabíamos sobre a criação de uma experiência de usuário rica no navegador. Enquanto o Flash oferecia uma API única e abrangente para todos os aspectos do desenvolvimento de aplicativos, desde a exibição de vetores até a detecção de polígonos e a análise de XML, o HTML5 oferecia uma mistura de especificações com suporte a vários navegadores. Também nos perguntamos se o HTML, uma linguagem específica para documentos, e o CSS, uma linguagem centrada em caixas, eram adequados para criar um jogo. O jogo vai aparecer de forma uniforme em todos os navegadores, como no Flash, e vai ter a mesma aparência e comportamento? Para o Wordico, a resposta foi sim.

Qual é o seu vetor, Victor?

Desenvolvemos a versão original do Wordico usando apenas gráficos vetoriais: linhas, curvas, preenchimentos e gradientes. O resultado foi altamente compacto e infinitamente escalonável:

Wireframe do Wordico
No Flash, todos os objetos de exibição eram feitos de formas vetoriais.

Também aproveitamos a linha do tempo do Flash para criar objetos com vários estados. Por exemplo, usamos nove keyframes nomeados para o objeto Space:

Um espaço de três letras no Flash.
Um espaço de três letras no Flash.

No HTML5, no entanto, usamos um sprite com mapa de bits:

Um sprite PNG mostrando todos os nove espaços.
Um sprite PNG mostrando todos os nove espaços.

Para criar o tabuleiro de jogo de 15 x 15 a partir de espaços individuais, iteramos em uma notação de string de 225 caracteres em que cada espaço é representado por um caractere diferente (como "t" para letra tripla e "T" para palavra tripla). Essa foi uma operação simples no Flash. Simplesmente marcamos os espaços e os organizamos em uma grade:

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);

No HTML5, é um pouco mais complicado. Usamos o elemento <canvas>, uma superfície de desenho de bitmap, para pintar o tabuleiro um quadrado de cada vez. A primeira etapa é carregar o sprite de imagem. Depois que ele é carregado, iteramos pela notação de layout, desenhando uma parte diferente da imagem em cada iteração:

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;
}

Confira o resultado no navegador da Web. A tela em si tem uma sombra projetada do CSS:

No HTML5, o tabuleiro de jogo é um único elemento de tela.
No HTML5, o tabuleiro de jogo é um único elemento de tela.

A conversão do objeto de bloco foi um exercício semelhante. No Flash, usamos campos de texto e formas vetoriais:

O bloco Flash era uma combinação de campos de texto e formas vetoriais.
O bloco Flash era uma combinação de campos de texto e formas vetoriais.

No HTML5, combinamos três sprites de imagem em um único elemento <canvas> no momento da execução:

O bloco HTML é um composto de três imagens.
O bloco HTML é um composto de três imagens.

Agora temos 100 telas (uma para cada bloco) e uma tela para o tabuleiro. Confira a marcação de um bloco "H":

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

Confira o CSS correspondente:

.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 efeitos CSS3 quando o bloco está sendo arrastado (sombra, opacidade e escalonamento) e quando ele está no rack (reflexão):

O bloco arrastado é um pouco maior, um pouco transparente e tem uma sombra.
O bloco arrastado é um pouco maior, um pouco transparente e tem uma sombra projetada.

O uso de imagens raster tem algumas vantagens óbvias. Primeiro, o resultado é preciso até o pixel. Em segundo lugar, essas imagens podem ser armazenadas em cache pelo navegador. Terceiro, com um pouco mais de trabalho, podemos trocar as imagens para criar novos designs de blocos, como um bloco de metal, e esse trabalho de design pode ser feito no Photoshop, em vez do Flash.

A desvantagem? Ao usar imagens, abrimos mão do acesso programático aos campos de texto. No Flash, era uma operação simples mudar a cor ou outras propriedades do tipo. No HTML5, essas propriedades são incorporadas às imagens. Tentamos usar texto HTML, mas isso exigia muito mais marcação e CSS. Também testamos o texto da tela, mas os resultados foram inconsistentes entre os navegadores.)

Lógica difusa

Queríamos usar a janela do navegador em qualquer tamanho e evitar a rolagem. Essa era uma operação relativamente simples no Flash, já que todo o jogo era desenhado em vetores e podia ser aumentado ou diminuído sem perder fidelidade. Mas era mais complicado em HTML. Tentamos usar a escalação de CSS, mas acabamos com uma tela desfocada:

Dimensionamento do CSS (à esquerda) e reenvio (à direita).
Dimensionamento do CSS (à esquerda) em comparação com a restauração (à direita).

Nossa solução é redesenhar o tabuleiro, o rack e os blocos sempre que o usuário redimensionar o navegador:

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

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

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

O resultado são imagens nítidas e layouts agradáveis em qualquer tamanho de tela:

O tabuleiro de jogo preenche o espaço vertical, e os outros elementos da página fluem ao redor dele.
O tabuleiro de jogo preenche o espaço vertical, e os outros elementos da página fluem ao redor dele.

Seja direto

Como cada bloco é posicionado de forma absoluta e precisa ser alinhado com o tabuleiro e o suporte, precisamos de um sistema de posicionamento confiável. Usamos duas funções, Bounds e Point, para ajudar a gerenciar a localização de elementos no espaço global (a página HTML). Bounds descreve uma área retangular na página, enquanto Point descreve uma coordenada x,y em relação ao canto superior esquerdo da página (0,0), também conhecido como ponto de registro.

Com Bounds, podemos detectar a interseção de dois elementos retangulares (por exemplo, quando um bloco cruza o rack) ou se uma área retangular (como um espaço de duas letras) contém um ponto arbitrário (como o ponto central de um bloco). Confira a implementação de limites:

// 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 a coordenada absoluta (canto superior esquerdo) de qualquer elemento na página ou de um evento do mouse. Point também contém métodos para calcular distância e direção, que são necessários para criar efeitos de animação. Confira a implementação 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);
}

Essas funções formam a base dos recursos de arrastar e soltar e de animação. Por exemplo, usamos Bounds.intersects() para determinar se um bloco se sobrepõe a um espaço no tabuleiro. Usamos Point.vector() para determinar a direção de um bloco arrastado. E usamos Point.interpolate() em combinação com um timer para criar um tween de movimento ou um efeito de suavização.

Me deixo levar

Embora os layouts de tamanho fixo sejam mais fáceis de produzir no Flash, os layouts fluidos são muito mais fáceis de gerar com HTML e o modelo de caixa CSS. Considere a seguinte visualização de grade, com largura e altura variáveis:

Esse layout não tem dimensões fixas: as miniaturas fluem da esquerda para a direita, de cima para baixo.
Esse layout não tem dimensões fixas: as miniaturas fluem da esquerda para a direita, de cima para baixo.

Ou considere o painel de chat. A versão do Flash exigia vários manipuladores de eventos para responder às ações do mouse, uma máscara para a área rolável, matemática para calcular a posição de rolagem e muito mais código para juntar tudo.

O painel de chat no Flash era bonito, mas complexo.
O painel de chat no Flash era bonito, mas complexo.

A versão HTML, em comparação, é apenas um <div> com uma altura fixa e a propriedade de overflow definida como oculta. O rolagem não tem custo financeiro.

O modelo de caixa CSS em ação.
O modelo de caixa do CSS em ação.

Em casos como esse, tarefas de layout comuns, o HTML e o CSS superam o Flash.

Você está me ouvindo?

Tivemos problemas com a tag <audio>, que simplesmente não era capaz de reproduzir efeitos sonoros curtos repetidamente em determinados navegadores. Tentamos duas soluções alternativas. Primeiro, adicionamos silêncio aos arquivos de som para torná-los mais longos. Depois, tentamos alternar a reprodução em vários canais de áudio. Nenhuma das técnicas foi completamente eficaz ou elegante.

Por fim, decidimos criar nosso próprio player de áudio Flash e usar o áudio HTML5 como substituto. Confira o código básico no 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);

No JavaScript, tentamos detectar o player Flash incorporado. Se isso falhar, criaremos um nó <audio> para cada arquivo de som:

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();
}
}

Isso funciona apenas para arquivos MP3. Não oferecemos suporte a OGG. Esperamos que o setor estabeleça um único formato em breve.

Posição da enquete

Usamos a mesma técnica no HTML5 que usamos no Flash para atualizar o estado do jogo: a cada 10 segundos, o cliente solicita atualizações ao servidor. Se o estado do jogo tiver mudado desde a última pesquisa, o cliente vai receber e processar as mudanças. Caso contrário, nada vai acontecer. Essa técnica de pesquisa tradicional é aceitável, mas não é muito elegante. No entanto, gostaríamos de mudar para long polling ou WebSockets à medida que o jogo amadurece e os usuários esperam interação em tempo real pela rede. Os WebSockets, em particular, oferecem muitas oportunidades para melhorar a jogabilidade.

Que ferramenta!

Usamos o Google Web Toolkit (GWT, na sigla em inglês) para desenvolver a interface do usuário e a lógica de controle de back-end (autenticação, validação, persistência etc.). O JavaScript em si é compilado a partir do código-fonte Java. Por exemplo, a função Point é adaptada 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));
}
...
}

Algumas classes de interface têm arquivos de modelo correspondentes em que os elementos da página são "vinculados" aos membros da classe. Por exemplo, 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>

Os detalhes completos estão fora do escopo deste artigo, mas recomendamos que você confira o GWT para seu próximo projeto HTML5.

Por que usar Java? Primeiro, para tipagem estrita. Embora a digitação dinâmica seja útil no JavaScript, por exemplo, a capacidade de uma matriz armazenar valores de tipos diferentes, ela pode ser uma dor de cabeça em projetos grandes e complexos. Segundo, para recursos de refatoração. Considere como você mudaria uma assinatura de método JavaScript em milhares de linhas de código. Não é fácil! Mas com um bom ambiente de desenvolvimento integrado Java, é muito fácil. Por fim, para fins de teste. Criar testes de unidade para classes Java é melhor do que a técnica tradicional de "salvar e atualizar".

Resumo

Exceto pelos problemas de áudio, o HTML5 superou nossas expectativas. O Wordico não só tem a mesma aparência do Flash, como também é tão fluido e responsivo. Não conseguiríamos fazer isso sem o Canvas e o CSS3. Nosso próximo desafio: adaptar o Wordico para uso em dispositivos móveis.