Torre de Hanói usando jQuery e CSS | Intel® Software
Torre de Hanói usando jQuery e CSS
Por PETER J. (Intel), publicado em 22 de janeiro de 2013
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.
Deve se adaptar a todos os tamanhos de tela
Deve funcionar em todos os principais navegadores
A conversão para um app híbrido deve ser facilitada
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.
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:
... // 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:
// 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
// Remove um element do surface
// ielem: indíce do elemento a ser removido. Este deve ser um
// valor retornado por Add_Elem
// 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
// Posiciona todos os elementos adicionados ao surface, baseados no
// escalonamento atual
// 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
// 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
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
// cria o objeto surface.  Escolhendo um sistema de coordenadas de 30x30
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:
// cria o objeto jQuery correspondente aos pinos e pratos
// Adiciona os pinos ao surface
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:
var height     = 20/disc_count;
var y          = 29 - height;
var disc = $("<DIV></DIV>").addClass ("disc").css('background-color', colors [i]);
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:
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:
// faz o posicionamento e dimensionamento inicial
// configura o handler a ser chamado quando a janela for redimensionada
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:
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:
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:
Process_Queue ();  // para ter a fila andando novamente
pins [0] = discs.slice (0);  // cria um clone da matriz de discos
Move_Stack (pins [0].length, 0, 2, 1);  // preenche a fila de movimentações
if (number_of_discs < 14) {
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:
O CSS para os pinos, pratos, discos e controles parece bem familiar:
background:#a00000;
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
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.
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.
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.
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.
Fazer com que as sombras e os cantos arredondados sejam mostrados no Internet Explorer.
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.