Torre de Hanói usando jQuery e CSS

Este artigo descreve o design e a implementação de um jogo simples de Torre de Hanói. O jogo provê uma animação gráfica que move os discos do pino mais à esquerda ao pino mais à direita. O usuário controla a velocidade da animação e quantos discos serão usados. Este app foi escrito usando HTML5 'portável' e roda na maioria dos navegadores. Isso facilita a criação de uma versão híbrida do app.

O código fonte deste exemplo pode ser encontrado aqui: https://github.com/gomobile/sample-towers-of-hanoi.

Aparência Final

Requisitos

  1. Deve se adaptar a todos os tamanhos de tela
  2. Deve funcionar em todos os principais navegadores
  3. A conversão para um app híbrido deve ser facilitada

Considerações de Design

A primeira coisa a ser notada é que os elementos visuais do app são todos retângulos, ex. discos, pinos, pratos e botões de controle. As proporções de largura/altura destes retângulos são flexíveis e podem ser ajustadas para preencher todo o espaço disponível na tela. Portanto, faz sentido ter todos estes elementos como tags HTML, com altura e largura sendo dinamicamente ajustados.

O número de discos é variável, o que torna natural a criação dinâmica dos elementos visuais.

O jQuery é um framework conveniente para a criação dinâmica de conteúdo HTML, e ele provê um método .animate bem útil para animar a posição de cada elemento na página, e ele é perfeito para o que precisamos fazer. o jQuery também é uma forma conveninente para escrever código 'independente do navegador'. Ele abstrai as diferenças entre navegadores.

Sistema de Coordenadas Virtuais

Para ajustar com facilidade o posicionamento e as dimensões dos elementos na página, um sistema de coordenadas virtuais é criado. A ideia é que a posição e a dimensão de cada elemento na página seja especificado usando as 'coordenadas virtuais'. O sistema de coordenadas virtuais terá sempre as mesmas dimensões, independentes das dimensões atuais da view. As dimensões serão expandidas ou enconlhidas para caber. A ideia é ilustrada aqui:

As duas ilustrações acima mostram como uma caixa vermelha é colocada em um sistema de coordenadas virtuais de 30 x 30. A caixa vermelha possui as dimensões virtuais (17 x 6) e é posicionada com seu canto superior esquerdo em (10, 5). As ilustrações mostram como o sistema de coordenadas viruais e os elementos contidos nela se adaptam às dimensões da tela.

Estrutura Geral

Muito do código JavaScript apresentado, exceto pela definição do objeto Surface, está aninhada dentro da função .ready do jquery. O framework jQuery irá chamar esta função assim que todos os elementos necessários do DOM forem carregados. Ela está configurada como segue:

	$(document).ready (function () {
    ... // este código é executado quando o DOM estiver carregado
}

	

Objeto Surface

Para gerenciar os objetos adicionados ao sistema de coordenadas virtuais, um objeto dedicado, Surface, é criado. Ele cuidará do mapeamento entre o sistema de coordenadas virtuais e os pixels da tela. 

To manage the objects added to the virtual coordinate system a dedicated object, Surface, is created. It will take care of managing the mapping between virtual coordinates and pixels on the screen. O esboço do construtor deste objeto é o seguinte:

 

	function Surface (jelem, width, height) {
    
    // Adiciona um elemento ao surface
    // jelem:  objeto jQuery representando o elemento DOM
    // x,y:    Canto superior esquerdo (coordenadas virtuais)
    // w,h:    Largura e altura do elemento em (tamanhos de 

	    //         coordenadas virtuais)
    // return: Um inteiro representando o elemento surface criado

    this.Add_Elem = function (jelem, x, y, w, h) {...};

    
    // Remove um element do surface
    // ielem: indíce do elemento a ser removido. Este deve ser um 

	    // valor retornado por Add_Elem
    
    this.Remove_Elem = function (ielem) {...};

    
    // Seta o escalonamento das coordenadas virtuais para posições  
    // reais dos pixels na tela.

	    // pwidth,pheight:  Altura e largura em pixels do surface para o qual 
    //                  as coordenadas virtuais devem ser mapeadas

	                        
    this.Set_Scale = function (pwidth, pheight) {...};

    
    // Posiciona todos os elementos adicionados ao surface, baseados no
    // escalonamento atual
    
    this.Position_All = function () {...};

    // Move um elemento da sua posição atual para uma nova posição
    // to_x, to_y: as coordenadas virtuais do destino
    // callback: uma função que será chamada quando a movimentação ocorrer. 
    //           Isto é relevante quando a movimentação for animada

	    //           e não acontecer imediatamente

	    
    this.Move_Elem = function (ielem, to_x, to_y, callback) {...};

        
    // Altera a duração da animação de uma movimentação
    // offset:  A alteração para a duração atual em milisegundos.
    //          A duração inicial é de 300ms
    
    this.Change_Duration = function (offset) {...};
}

	

Existe apenas uma instância deste objeto e ela é mapeada para o único elemento DOM dentro do corpo do documento HTML. Todos os outros elementos DOM são criados dinamicamente.

	    ...
    // cria o objeto jquery correspondente ao elemento DOM que fixa o 
    // sistema de coordenadas virtuais
    var jsurface = $("#surface");
    
    // cria o objeto surface.  Escolhendo um sistema de coordenadas de 30x30
    var surface  = new Surface (jsurface, 30, 30);
    ...
    
<BODY>
<DIV id="surface"></DIV>
</BODY>

	

As dimensões (30x30) foram escolhidas de forma arbitrária. Note que é possível usar valores fracionados para as posições, então isto não limita a granularidade dos tamanhos e posições dos elementos dentro do objeto surface.

Criando Pinos e Controles

Os pinos e os botões de controle são criados com esta função:

	    function Create_Pins () {
        

	        // cria o objeto jQuery correspondente aos pinos e pratos
        var jpin1 = $("<DIV></DIV>").addClass ("pin").appendTo (jsurface);
        var jpin2 = $("<DIV></DIV>").addClass ("pin").appendTo (jsurface);
        var jpin3 = $("<DIV></DIV>").addClass ("pin").appendTo (jsurface);
        var jplate1 = $("<DIV></DIV>").addClass ("plate").appendTo (jsurface);
        var jplate2 = $("<DIV></DIV>").addClass ("plate").appendTo (jsurface);
        var jplate3 = $("<DIV></DIV>").addClass ("plate").appendTo (jsurface);
        
        // Adiciona os pinos ao surface
        var pin1 = surface.Add_Elem (jpin1, 4.5,  5, 1, 25);
        var pin2 = surface.Add_Elem (jpin2, 14.5, 5, 1, 25);
        var pin3 = surface.Add_Elem (jpin3, 24.5, 5, 1, 25);
        var plate1 = surface.Add_Elem (jplate1, 1, 29, 8, 1);
        var plate2 = surface.Add_Elem (jplate2, 11, 29, 8, 1);
        var plate3 = surface.Add_Elem (jplate3, 21, 29, 8, 1);
    }
    
    function Create_Controls () {
        surface.Add_Elem ($('<INPUT type="button" value="Start" />')
            .addClass ("control").appendTo (jsurface).click (Start), 1, 1, 3, 1);
        surface.Add_Elem ($('<INPUT type="button" value="Stop" />')
            .addClass ("control").appendTo (jsurface).click (Stop), 5, 1, 3, 1);
        surface.Add_Elem ($('<INPUT type="button" value="Reset" />')
            .addClass ("control").appendTo (jsurface).click (Reset), 9, 1, 3, 1);
        surface.Add_Elem ($('<INPUT type="button" value="+1 Disc" />')
            .addClass ("control").appendTo (jsurface).click (Add1), 13, 1, 3, 1);
        surface.Add_Elem ($('<INPUT type="button" value="-1 Disc" />')
            .addClass ("control").appendTo (jsurface).click (Sub1), 17, 1, 3, 1);
        surface.Add_Elem ($('<INPUT type="button" value="Faster" />')
            .addClass ("control").appendTo (jsurface).click (Faster), 21, 1, 3, 1);
        surface.Add_Elem ($('<INPUT type="button" value="Slower" />')
            .addClass ("control").appendTo (jsurface).click (Slower), 25, 1, 3, 1);
    }

	

Os objetos jQuery para os pinos e pratos são criados usando a função ($) do jQuery para criar um elemento <DIV> ao qual uma classe é adicionada com o método addClass. O método .appendTo é usado para adicionar o recém criado elemento DOM como o último elemento DOM dentro do elemento DOM pai do jsurface. Note o encadeamento das chamadas dos métodos do jQuery. Cada chamada do jQuery irá retornar um objeto jQuery representando o objeto modificado. Este golpe de mestre é o possibilita este encadeamento muito conveniente.

Cada prato tem a largura de 8 e é separado por 2. Os pratos da direita e o mais à esquerda estão colocados a 1 do canto. Cada pino possui 1 de largura e 25 de altura e está colocado no meio dos pratos e a 5 do topo. Assim é como as coorenadas mágicas mostradas acima foram decididas. Fique á vontade para se divertir com a matemática.

Os controles são criados como elementos <INPUT> do HTML. Além de adicionar uma classe para controlar o layout, um handler de clique também foi preparado para cada um deles. Ele especifica a função JavaScript que deve ser chamada quando o controle é clicado. Os handlers são funções simples que simplesmente modificam variáveis de estado para obter os efeitos desejados.

Criando e Removendo os Discos

Os discos são criados pela função abaixo:

	    function Create_Discs (disc_count) {
        var max_width  = 7;
        var min_width  = 3;
        var width_step = (max_width - min_width)/(disc_count - 1);
        var x_step     = width_step/2;
        var height     = 20/disc_count;
        var width      = max_width;
        var x          = 1.5;
        var y          = 29 - height;
        var discs = new Array ();
        for (var i = 0; i < disc_count; ++i) {
            var disc = $("<DIV></DIV>").addClass ("disc").css('background-color', colors [i]);
            disc.appendTo (jsurface);
            discs.push (surface.Add_Elem (disc, x, y, width, height));
            x = x + x_step;
            width = width - width_step;
            y = y - height;
        }
        return discs;
    }

	

Tendo em visa que precisamos ter a capacidade de deixar o usuário especificar o número de discos, um parâmetro 'disc_count' é usado por esta função. A largura dos discos varia entre 7 para o mais largo e 3 para o menor deles. A altura dos discos é calculada, de forma que a altura total da pilha seja 20. Outra possibilidade é a de ter uma altura constante para os discos. É fácil implementar isso com uma pequena alteração no código acima. Também note que a propriedade background do CSS está setada para uma matriz de cores. Isso fará com que os discos tenham cores diferentes.

Como o número de discos podem variar, nós podemos precisar remover alguns discos existentes do surface para abrir espaço para novos discos. Isto é feito com esta função:

	    function Remove_Discs () {
        if (typeof discs !== 'undefined') {
            for (var i = 0; i < discs.length; ++i) {
                var elem = surface.Get_Elem (discs [i]);
                elem.jelem.remove();
                surface.Remove_Elem (discs [i]);
            }
            delete discs;
        }
    }

	

Os discos criados são armazenados na matriz de discos durante a criação. Desta forma, temos todos os handles de surface disponíveis quando eles devem ser removidos do surface. Note que cada disco precisa ser removido do DOM e do surface. A remoção do DOM é feita com o método .remove() do jQuery e a remoção do surface é feita com o método .Remove_Elem().

Trastando o Redimensionamento da Janela

Quando a janela é redimensionada, o mapeamento entre as coordenadas virtuais e pixels deve ser atualizado. Isto é feito setando um handler para o evento resize do jQuery, como segue:

	    function Resize (width, height) {
        jsurface.width (width);
        jsurface.height (height);
        surface.Set_Scale (width, height);
        surface.Position_All ();
    }
    
    function Resize_To_Window () {
        Resize ($(window).width(), $(window).height());
    }

    ...
    
        // faz o posicionamento e dimensionamento inicial
        Resize_To_Window ();

        // configura o handler a ser chamado quando a janela for redimensionada
        $(window).resize (Resize_To_Window);

	

A função Resize_To_Window() recupera a altura e largura da janela em pixels usando os métodos .width e .height. Estes valores são então usados para ajustar o tamanho em pixels do elemento DOM que representa o surface. O método do surface para fazer o escalonamento e reposicionamento de  todos os elementos que ele coordena, Position_All(), é chamado depois que os fatores de escalonamento tiverem sido setados com a chamada para Set_Scale(). A função Resize_To_Window() é setada para ser chamada sempre que o tamanho da janela é alterado, usando o método .resize do jQuery.

Sequenciando a Animação

O código para se mover um disco de um pino para outro se parece com este:

	    function Move_Disc (from_pin, to_pin, callback) {
        var from_pin_discs = pins [from_pin];
        var from_top_disc  = pins [from_pin].pop ();
        var x_move         = (to_pin - from_pin)*10;
        var elem           = surface.Get_Elem (from_top_disc);
        
        pins [to_pin].push (from_top_disc);
        surface.Move_Elem (from_top_disc, elem.x, 5 - elem.h);
        surface.Move_Elem (from_top_disc, elem.x + x_move, 5 - elem.h);
        surface.Move_Elem (from_top_disc,
                           elem.x, 29 - elem.h*(pins[to_pin].length), callback);
    }

	

Os pinos são indexados com 0, 1, 2 e a posição atual de onde os discos estão localizados é mantida em matrizes, uma para cada pino. Os elementos destas matrizes são os idendificadores dos discos no surface.

Cada movimento de disco é feito através de três movimentos individuais; em direção á parte de cima do pino onde ele atualmente se encontra, em direção ao pino de destino e se encaixando no pino de destino. As distâncias para a viagem nas direções x e y são derivadas das posições dos pinos e do tamanho da pilha no pino de destino.

A razão para o parâmetro de callback é ter uma forma de indicar à função que chamou, que a movimentação foi completada. Devido a animação, a movimentação não irá acontecer imediatamente. Quando múltiplas animações forem sequenciadas no mesmo elemento, o jQuery irá tratar bem o sequenciamento. Mesmo assim, ele não é capaz de tratar o sequenciamento quando múltiplos elementos precisam ser sequenciados, então precisamos fazer isso 'manualmente'.

Optei por introduzir uma fila de movimentações que são processadas em sequência:

	    function Move_Disc_Queue (from_pin, to_pin) {
        queue.push ({from:from_pin, to:to_pin});
    }
    
    function Process_Queue () {
        if (queue.length > 0 && state == 'running') {
            var elem = queue.shift ();
            Move_Disc (elem.from, elem.to, Process_Queue);
        }
    }

    function Move_Stack (size, from, to, middle) {
        if (size == 1) {
            Move_Disc_Queue (from, to);
        }
        else {
            Move_Stack (size-1, from, middle, to);
            Move_Disc_Queue (from, to);
            Move_Stack (size-1, middle, to, from);
        }
    }

	

Cada chamada para o Move_Disc_Queue() fará simplesmente a criação da fila de movimentações na matriz de fila. Os leitores devem reconhecer o padrão recursivo da implementação do algoritmo da Torre de Hanói. Quando a fila estiver pronta para ser processada, uma chamada para Process_Queue() é lançada. Isso dará início ao processamento da fila. Note que a Process_Queue é fornecida como um callback para ser chamado quando a animação atual termina, fazendo com que o próximo disco seja movido. Também note que a variável 'state' deve ser setada para 'running' para que o processamento dos elementos da fila continue. Isso permite que o usuário pare temporariamente a animação em qualquer tempo.

As operações padrão de matriz .push e .shift são usadas para adicionar ao final da fila e para remover o primeiro elemento da fila, respectivamente.

Respondendo às Entradas do Usuário 

Os handlers que setamos para tratar os cliques nos controles são os seguintes:

	    function Start () {
        if (state == 'stopped') {
            state = 'running';
            Process_Queue ();  // para ter a fila andando novamente
        }
    }
    
    function Stop () {
        state = 'stopped';
    }
        
    function Reset () {
        if (state == 'stopped') {
            Remove_Discs ();
            discs    = Create_Discs (number_of_discs);
            pins [0] = discs.slice (0);  // cria um clone da matriz de discos 
            pins [1] = new Array ();
            pins [2] = new Array ();
            Resize_To_Window ();
            delete queue;
            queue = new Array ();
            Move_Stack (pins [0].length, 0, 2, 1);  // preenche a fila de movimentações
        }
    }

    function Faster () {
        surface.Change_Duration (-50);
    }
    
    function Slower () {
        surface.Change_Duration (50);
    }
    
    function Add1 () {
        if (state == 'stopped') {
            if (number_of_discs < 14) {
                number_of_discs += 1;
            }
            Reset ();
        }
    }
    
    function Sub1 () {
        if (state == 'stopped') {
            if (number_of_discs > 2) {
                number_of_discs -= 1;
            }
            Reset ();
        }
    }

	

Uma coisa para se notar aqui é o uso da variável 'state'. Ela pode ter dois valores: 'stopped' e 'running'. A adição e a subtração do número de discos só pode ser feita no estado 'stopped'. A função Reset() irá remover todos os discos existentes e criar novos, baseado no número atual de discos selecionados e irá ainda coloca-los todos no pino inicial. A duração inicial da animação está configurada como 301ms, e a alteração que ocorre a cada vez que os botões 'faster' e 'slower' são clicados é de 50ms. Isso significa que a duração mínima é de 1ms, que a menor granularidade para se tratar de tempo usando JavaScript.

Fazendo com que ser Pareça Bonito

Finalmente, eu adicionei algum CSS para fazer com que os elementos se fiquem bonitos e para remover as bordas e a barra de rolagem.

Este CSS remove a barra de rolagem e seta as bordas do elemento BODY para cobrir a janela toda:

	HTML {
    overflow:hidden;
}

BODY {
    margin:0;
}

	

O CSS para os pinos, pratos, discos e controles parece bem familiar:

	.pin {
    position:absolute;
    border-radius:20px;
    background:#a00000;
    -moz-box-shadow: 8px 0px 8px #333;
    -webkit-box-shadow: 8px 0px 8px #333;
    box-shadow: 8px 0px 8px #333;
}

.plate {
    position:absolute;
    background:#a00000;
    -moz-box-shadow: 8px 0px 8px #333;
    -webkit-box-shadow: 8px 0px 8px #333;
    box-shadow: 8px 0px 8px #333;
}

.disc {
    position:absolute;
    border-radius:20px;
    -moz-box-shadow: 8px 0px 8px #333;
    -webkit-box-shadow: 8px 0px 8px #333;
    box-shadow: 8px 0px 8px #333;
}

.control {
    position:absolute;
    background:#a00;
    color:#fff;
    font-family: Verdana;
    font-weight: bold;
}

	

O aspecto mais importante destes é o 'position:absolute'. Se o posicionamento não estiver setado para ser absoluto, o posicionamento no sistema de coordenadas virtuais não vai funcionar.

A sombra é adicionada da mesma forma nos pinos, pratos e discos, para dar um efeito 3D. Cantos arredondados também foram adicionados aos discos e pinos.

Outras Coisas para Considerar 

  1. O tamanho da fonte do texto usado nos botões de controle não é redimensionado com a janela. Existem duas formas que penso que podem fazer com que isso aconteça; Ou usar uma imagem para o controle, que seja redimensionada para caber no controle ou ter a função Position_All() ajustando o tamanho da fonte dos elementos que ela gerencia.
  2. Parece que o evento resize nem sempre é disparado quando a orientação é alterada. Para consertar isso, a função Resize_To_Window() pode ser chamada quando o evento orientationchange for disparado.
  3. Quando se clica rapidamente nos controles, alguns destes cliques podem disparar o comportamento de double-click-to-zoom de alguns navegadores (iOS). Para prevenir que o zoom aconteça, ele deve ser desabilitado. Eu acredito que isso possa ser feito usando um tag META. Só não tive tempo de ver isso ainda.
  4. Quando o app é executado em um navegador móvel, existe uma forma de se livrar de todos os controles do navegador (botões voltar, favoritos, etc). Isso deve ser feito para deixar mais espaço para o app.
  5. Fazer com que as sombras e os cantos arredondados sejam mostrados no Internet Explorer.
  6. Para melhorar os efeitos 3D, um gradiente radial pode ser adicionado aos discos, fazendo-os parecer redondos. O modo pelo qual os gradientes radiais são tratados por diferentes navegadores é muito diferente, então este será um bom exercício.
Para obter informações mais completas sobre otimizações do compilador, consulte nosso aviso de otimização.