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

Introdução

Quando convertemos nosso jogo de palavras cruzadas Wordico de Flash para HTML5, nossa primeira tarefa foi descansar tudo o que sabíamos sobre como criar uma experiência do usuário avançada no navegador. Embora o Flash oferecesse uma API única e abrangente para todos os aspectos do desenvolvimento de aplicativos, desde desenho vetorial e detecção de ocorrências em polígonos até análise XML, o HTML5 oferecia uma série de especificações com suporte variado ao navegador. Também nos perguntamos se o HTML, uma linguagem específica de documento, e o CSS, uma linguagem centrada em caixas, eram adequados para a criação de um jogo. O jogo será exibido de maneira uniforme em todos os navegadores, assim como no Flash, e teria a mesma aparência e o mesmo comportamento? Para o Wordico, a resposta foi sim.

Qual é 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 de 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 frames-chave nomeados para o objeto Space:

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

No entanto, em HTML5, usamos um sprite bitmap:

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

Para criar o gameboard de 15 x 15 usando espaços individuais, iteramos 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. Nós 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 em bitmap, para pintar o gameboard um quadrado de cada vez. A primeira etapa é carregar o sprite da imagem. Depois do carregamento, iteramos a notação de layout, desenhando uma parte diferente da imagem a 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;
}

Aqui está o resultado no navegador da Web. Observe que a tela em si tem uma sombra projetada em CSS:

Em HTML5, o tabuleiro de jogos é um único elemento de tela.
Em HTML5, o gameboard é um único elemento de tela.

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

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

Em HTML5, combinamos três sprites de imagem em um único elemento <canvas> durante a execução:

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

Agora temos 100 telas (uma para cada bloco), além de uma tela para o tabuleiro. Esta é a marcação para 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 dimensionamento) e quando o bloco está no rack (reflexão):

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

O uso de imagens de varredura tem algumas vantagens óbvias. Primeiro, o resultado é preciso. Segundo, 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 uma peça de metal - e esse trabalho de design pode ser feito no Photoshop em vez de em Flash.

A desvantagem? Ao usar imagens, desistimos do acesso programático aos campos de texto. No Flash, era uma operação simples alterar a cor ou outras propriedades do tipo. No HTML5, essas propriedades são incorporadas às próprias imagens. Tentamos usar texto HTML, mas foi necessário usar muitas marcações e CSS extras. Também tentamos usar o texto em tela, mas os resultados eram inconsistentes em todos os navegadores.

Lógica difusa

Queríamos usar ao máximo a janela do navegador em qualquer tamanho, sem precisar rolar a tela. Essa era uma operação relativamente simples em Flash, já que o jogo todo era desenhado em vetores e poderia ser dimensionado para mais ou para menos sem perder a fidelidade. Mas era mais complicado em HTML. Tentamos usar o escalonamento CSS, mas acabamos com uma tela desfocada:

Escalonamento de CSS (esquerda) x redesenho (direita).
Escalonamento de CSS (à esquerda) x redesenho (à direita).

Nossa solução é redesenhar o gameboard, 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);
});

Chegamos a imagens nítidas e layouts agradáveis em qualquer tamanho de tela:

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

Vá direto ao ponto

Como cada bloco está totalmente posicionado e precisa estar alinhado precisamente ao tabuleiro e ao rack, precisamos de um sistema confiável de posicionamento. Usamos duas funções, Bounds e Point, para ajudar a gerenciar a localização dos 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 conhecida como o ponto de registro.

Com Bounds, podemos detectar a interseção de dois elementos retangulares (como quando um bloco cruza o rack) ou se uma área retangular (como um espaço com 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 a distância e a direçã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 uma interpolação de movimento ou efeito de easing.

Me deixo levar

Embora layouts de tamanho fixo sejam mais fáceis de produzir em Flash, layouts fluidos são muito mais fáceis de gerar com HTML e o modelo de caixa CSS. Considere a seguinte visualização em 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 de rolagem, matemática para calcular a posição de rolagem e muitos outros códigos para uni-la.

O painel do chat no Flash era bem, mas complexo.
O painel de chat no Flash era bem complexo, mas era bem complexo.

Em comparação, a versão HTML é apenas uma <div> com altura fixa e a propriedade flutuante definida como oculta. A rolagem não custa nada.

O modelo de box CSS em ação.
O modelo de box do CSS em funcionamento.

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

Você está me ouvindo agora?

Tivemos problemas com a tag <audio> porque ela simplesmente não era capaz de reproduzir efeitos sonoros curtos repetidamente em determinados navegadores. Tentamos duas soluções alternativas. Primeiro, preenchemos os arquivos de som com ar morto para deixá-los mais longos. Depois, tentamos alternar a reprodução em vários canais de áudio. Nenhuma das técnicas era completamente eficaz ou elegante.

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

Em JavaScript, tentamos detectar o Flash player 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();
}
}

Esse recurso funciona apenas para arquivos MP3. Nunca nos preocupamos em oferecer suporte a OGG. Esperamos que o setor escolha um único formato no futuro próximo.

Posição da enquete

Usamos no HTML5 a mesma técnica usada em 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 enquete, o cliente vai receber e processar as mudanças. Caso contrário, nada acontecerá. Essa técnica de votação tradicional é aceitável, se não muito elegante. No entanto, gostaríamos de mudar para pesquisas longas ou WebSockets à medida que o jogo amadurecer e os usuários passarem a esperar interação em tempo real pela rede. Os WebSockets, em especial, ofereceriam muitas oportunidades para melhorar o jogo.

Que ferramenta!

Usamos o Google Web Toolkit (GWT) para desenvolver a interface de usuário de front-end e a lógica de controle de back-end (autenticação, validação, persistência e assim por diante). O próprio JavaScript é 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 além do escopo deste artigo, mas recomendamos que você acesse o GWT para seu próximo projeto HTML5.

Por que usar Java? Primeiro, para digitação restrita. Embora a digitação dinâmica seja útil em JavaScript - por exemplo, a capacidade de uma matriz de conter valores de tipos diferentes - ela pode ser uma dor de cabeça em projetos grandes e complexos. Segundo, para recursos de refatoração. Pense em como você mudaria uma assinatura de método JavaScript em milhares de linhas de código, de maneira fácil! Mas, com um bom IDE em Java, é instantâneo. Por fim, para fins de teste. A criação de testes de unidade para classes Java supera a técnica consagrada de "salvar e atualizar".

Resumo

Exceto por problemas de áudio, o HTML5 superou muito nossas expectativas. Além de ter uma aparência tão boa quanto no Flash, o Wordico é fluido e responsivo. Não teríamos conseguido fazer isso sem o Canvas e o CSS3. Nosso próximo desafio: adaptar o Wordico para uso em dispositivos móveis.