Tetris 3/3: Toques Finais

Com certeza você já jogou TETRIS. É um dos jogos mais famosos de todos os tempos, e é por ele que vou começar meus tutoriais de desenvolvimento de jogos em HTML5. Quero lembrar a todos que eu faço jogos por diversão, então não assumam que o conteúdo aqui é exatamente profissional. Espero que vocês consigam aprender alguns conceitos sobre jogos em HTML5 comigo e que talvez seja o suficiente pra você se aventurar e fazer seus próprios jogos. Dito isso, vamos ao que interessa ;)
Quando uma peça colide com algo, o jogo deve verificar se alguma linha está completa. Se ela estiver, deve ser destruída.

Na segunda parte desse tutorial, a gente implementou todo o controle das peças, as colisões e a criação de novas peças quando uma colisão acontece. Agora vamos começar essa terceira e última parte fazendo a verificação de linhas completas. Existem dois tipos de colisão: a com a borda inferior (o 'fundo' do grid) e a com outras peças. Ambas chamam o método classCanvas.criaPiece... então é nele que vamos adicionar essa verificação, logo antes de criar uma nova peça.

// método para criar uma nova peça
	this.criaPiece = function() {
    	// caso já exista uma peça atual
    	if (this.pieceAtual) {
			// então vamos marcar as posições acupadas pela peça atual
			this.marcaPosicoesOcupada();
			// e então vamos verificar se alguma linha está completa
			this.verificaLinhasCcompletas();
		}
		(...)

Agora vamos implementar essa verificação de linhas completas. É bem simples até!

// método pra verificar se alguma linha está completa e destruí-la
	this.verificaLinhasCompletas = function() {
		// vamos começar da última linha, e ir subindo
		var y = this.altura-1;
		while (y >= 0) {
			// a gente começa assumindo que essa linha está completa
			var completa = true;
			// vamos iterar todas as posições dessa linha
			for (var x = 0; x < this.largura; x++) {
				var ocupada = this.grid[x][y];
				// se essa posição não estiver ocupada
				if (!ocupada) {
					// quer dizer que a linha não está completa
					completa = false;
				}
			}
			// se a linha estiver completa
			if (completa) {
				// vamos mover 'tudo' pra baixo a partir dessa linha, pra ocupar o lugar dela
				// eu não diminuo o y aqui porque, como eu vou mover o grid, a linha de cima vai ocupar o lugar dessa
				// então precisamos analisar a 'mesma' linha de novo!
				this.moveGridPraBaixo(y);
			} else {
				// se não estiver, vamos conferir a linha de cima
				y--;				
			}
		}
	};

E por último, precisamos implementar a parte que move tudo pra 'baixo', pra ocupar o espaço da linha que foi destruída.

// método pra mover o grid pra baixo até um y especificado
	this.moveGridPraBaixo = function(ymin) {
		// agora vamos atualizar as posições ocupadas também
		// vamos iterar o grid inteiro, mas começando da ultima linha
	   for (var x = 0; x < this.largura; x++) {
	   		// ele vai começar do linha que foi destruída e vai subindo até o topo
			for (var y = ymin; y >= 0; y--) {
				// se o y for igual a zero, quer dizer que é a primeira linha
				if (y == 0) {
					// então logicamente agora ela vai estar livre
					this.grid[x][y] = false;
				} else {
					// se for qualquer outra linha, cada posição vai pegar a flag da linha de cima!
					// isto é, a peça está 'descendo'!
					this.grid[x][y] = this.grid[x][y-1];
				}
			}
		}
	};

Aqui em encontrei um problema. Toda vez que eu uma peça colide, eu salvo ela na lista de peças e o classCanvas.desenhaTudo desenha ela na posição certa. Mas quando uma linha é quebrada, partes das peças somem. A minha abordagem não entende isso muito bem, então eu não conseguia excluir pedaços das peças. Isso aconteceu porque eu fui implementando na medida em que estou escrevendo esse tutorial (erro de principiante, me desculpem! na próxima eu vou terminar o jogo e então quebrar ele em partes e ir criando os posts) e por isso vamos fazer pequena alteração na lógica.

Ao invés de marcar posições como 'ocupadas' e salvar as peças para serem desenhadas, eu vou marcar as posições com a cor da peça que acabou de colidir, assim eu posso mover as posições e ignorar o fato de que elas faziam parte de uma peça. Isto é, a partir do momento que uma peça colidiu, ela não é mais uma peça. Ela simplesmente faz parte de grid! Parece uma alteração complicada, mas é coisa boba. Vejam só:

// método pra marcar as posições da peça atual como ocupadas    
    this.marcaPosicoesOcupada = function() {
		(...)
		// marcamos essa posição como ocupada
		// this.grid[x][y] = true; ANTES
		this.grid[x][y] = p.data.cor; // NOVO
		(...)

Salvar a cor ao invés de 'true' é suficiente pra toda a lógica de colisão que eu fiz antes continuar a funcionar. Afinal, o javascript interpreta qualquer string como 'true', então qualquer IF que eu tenha feito (e eu tenho certeza que não fiz nenhum '=== true') não vai perceber a diferença.

Também preciso mudar a parte que desenha tudo. Antes ele desenhava todas as peças guardadas (o que incluía a peça atual). Agora não vou mais guardar/desenhar as peças guardadas... então preciso desenhar qualquer coisa salva no grid, além da peça atual. Eu poderia iterar o x/y do grid atrás de posições ocupadas, mas fazer isso pra CADA frame do canvas é extremamente não-performático. Vou atualizar então a classCanvas.marcaPosicoesOcupadas para, depois de fazer o que ela já faz, salvar as posições ocupadas (e SOMENTE as posições ocupadas) num vetor. Isso vai permitir acessá-las de maneira muito mais rápida, diminuindo bastante o impacto nos frames. Também vou precisar alterar o classCanvas.moveGridPraBaixo e fazer ele chamar esse método também. Como ele está mudando quais posições estão ocupadas com qual cor, eu preciso atualizar o vetor!

// método pra marcar as posições da peça atual como ocupadas    
    this.marcaPosicoesOcupada = function() {
    	(...)
		this.geraVetorPosicoes();
    };

	// método pra mover o grid pra baixo até um y especificado
	this.moveGridPraBaixo = function(ymin) {
		(...)
		this.geraVetorPosicoes();
	}
// inicializando o vetor
	this.posicoes = [];
	// método pra gerar um array das posições ocupadas
	this.geraVetorPosicoes = function() {
		// resetando o vetor
		this.posicoes = [];	
		// iterando o x/y do grid
    	for (var x = 0; x < this.largura; x++) {
    		for (var y = 0; y < this.altura; y++) {
				var cor = this.grid[x][y];
				// caso essa posição esteja ocupada, salvamos ela no vetor
				if (cor) this.posicoes.push({'x':x,'y':y,'cor':cor});
			}
		}
	};

Ótimo! Agora temos um vetor de posições que é atualizado sempre que a classCanvas.marcaPosicoesOcupadas é chamada (isto é, toda vez que uma colisão acontece). O próximo passo então é atualizar o funcionamento da classCanvas.desenhaTudo. Ele vai ficar assim agora:

// método para desenhar tudo
	this.desenhaTudo = function() {
		// limpando o canvas
		this.ctx.clearRect(0, 0, this.ctx_width, this.ctx_height);
		
		// desenhando qualquer coisa salva no grid
		this.posicoes.forEach(function(pos) {
			this.desenhaPosicao(pos);
		}.bind(this));
		
		// desenhando a peça atual
		this.desenhaPiece(this.pieceAtual);
		
		// cancelando algum request pendente 
		cancelAnimationFrame(this.requestFrame);
		// realizando um novo request, pra redesenhar assim que terminar esse frame
		this.requestFrame = window.requestAnimationFrame(function() { 
			this.desenhaTudo(); 
		}.bind(this));
	};

Eu criei um método novo, classCanvas.desenhaPosicao, e alterei o classCanvas.desenhaPiece pra usar esse novo método. Ficou assim:

// método para desenhar uma posição x,y com uma cor específica
	this.desenhaPosicao = function(pos) {
		// calculando o left/top do x/y passado
		var left = (pos.x)*this.tamanhoPos;
		var top = (pos.y)*this.tamanhoPos;
		// iniciando um traçado no context
		this.ctx.beginPath();
		// preparando um retangulo pra essa posição
		this.ctx.rect(left,top,this.tamanhoPos,this.tamanhoPos);
		// setando a cor do desenho
		this.ctx.fillStyle = pos.cor;
		// setando a cor da borda
		this.ctx.strokeStyle = '#000000';
		// desenhando
		this.ctx.fill(); 
		this.ctx.stroke();
	};
	
	// metodo para desenhar uma peça
	this.desenhaPiece = function(piece) {
		// buscando o map atual dessa peça
		var map = piece.data.maps[piece.mapAtual];
		// vamos iterar as posições e desenhá-las
		map.forEach(function(pos) {
			// calculando a linha dessa posição 
			var row = Math.floor(pos/4);
			// e a coluna dessa posição
			var col = pos%4;
			// calculando a posição left/top dele de acordo com seu x,y
			var pos = {'x':col+piece.x, 'y':row+piece.y, 'cor':piece.data.cor};
			this.desenhaPosicao(pos);
		}.bind(this));
	};

O resultado dessas mudanças vai ser esse aqui. Olha que bacana, agora as linhas são destruídas :D


Cada linha destruída gera pontos para o jogador. Destruir mais de uma linha de uma só vez gera pontos bonus.

Hora de contabilizar pontos! Primeiro, vamos adicionar um pequeno placar na nossa interface. Coisa simples. O nosso HTML e no CSS vão ficar assim:

<canvas id='grid'></canvas>

<div id='score'>
	Score:<br/>
	<span id='myscore'>000000</span>
</div>
#grid {
	border:1px solid black;
	background:white;
}

#score {
	font-family:monospace;
	display:inline-block;
	vertical-align:top;
	padding:5px 10px;
	font-size:20px;
}
#myscore {
	font-size:30px;
	font-weight:bold;
}

Deve ficar basicamente assim:

Ok... agora vamos falar sobre a pontuação. Essa parte é bem subjetiva, ficando a critério do desenvolvedor decidir como vai ser. Como exemplo, eu decidi que a cada vez que a peça atual desce uma linha, o jogador vai ganhar 5 pontos. Quando uma linha for destruída, ele vai ganhar 300 pontos. Se duas linhas foram destruídas ao mesmo tempo o jogador vai ganhar 800 pontos. 3 linhas = 1500 pontos e 4 linhas (o máximo possível) serão 2500 pontos. Vamos implementar isso então!

Primeiro, nós inicializamos a pontuação.

// método para iniciar o jogo
	this.iniciar = function() {
		// iniciando a pontuação
		this.pontos = 0;
		(...)

Agora, no final da classCanvas.movePieceAtual vamos verificar se foi um movimento que fez a peça descer, e caso seja, vamos creditar alguns pontos pro jogador.

// método pra mover a peça numa direção qualquer
    this.movePieceAtual = function(direcao) {
		(...)
		// caso a peça esteja descendo, vamos creditar 5 pontos
		if (direcao == 'down') this.ganhaPontos(5);
    };  

	// método para adicionar pontos ao score
	this.ganhaPontos = function(qtde) {
		console.log('ganhaPontos',qtde);
		// adicionando os pontos
		this.pontos += qtde;
		console.log(this.pontos);
		// gerando uma string com os zeros a esquerda pra exibir
		var dspontos = '000000'+this.pontos;
    	dspontos = dspontos.substr(dspontos.length-6);
		// atualizando o html da página
		$('#myscore')[0].innerHTML = dspontos;
	};

Ótimo. Agora vamos adicionar a pontuação na destruição de linhas também. A classCanvas.verificaLinhasCompletas vai ficar assim:

// método pra verificar se alguma linha está completa e destruí-la
	this.verificaLinhasCompletas = function() {
		// iniciamos um contador de linhas quebradas
		var quebradas = 0;
		// vamos começar da última linha, e ir subindo
		var y = this.altura-1;
		while (y >= 0) {
			// a gente começa assumindo que essa linha está completa
			var completa = true;
			// vamos iterar todas as posições dessa linha
			for (var x = 0; x < this.largura; x++) {
				var ocupada = this.grid[x][y];
				// se essa posição não estiver ocupada
				if (!ocupada) {
					// quer dizer que a linha não está completa
					completa = false;
				}
			}
			// se a linha estiver completa
			if (completa) {
				// contabilizamos a linha como quebrada
				quebradas++;
				// vamos mover 'tudo' pra baixo a partir dessa linha, pra ocupar o lugar dela
				// eu não diminuo o y aqui porque, como eu vou mover o grid, a linha de cima vai ocupar o lugar dessa
				// então precisamos analisar a 'mesma' linha de novo!
				this.moveGridPraBaixo(y);
			} else {
				// se não estiver, vamos conferir a linha de cima
				y--;				
			}
		}		
		// gerando um vetorzinho com a qtde de pontos por qtde quebrada
		var pontos = [0,300,800,1500,2500];
		// pegando a pontuação correta
		var qtde = pontos[quebradas];
		// creditando a pontuação
		this.ganhaPontos(qtde);
	};

Agora temos um placar! Tá praticamente pronto o/


O fluxo continua até que uma das peças, ao colidir, esteja encostada no topo do grid.

Ok, pra verificar isso, vamos adicionar uma pequena flag na peça atual assim que ela for criada. Essa flag vai gerenciar se essa peça conseguiu se mover ou não.

// método para criar uma nova peça
	this.criaPiece = function() {
		(...)
		// a peça acabou de ser criada, logicamente ela ainda não se moveu
		piece.seMoveu = false;
	}

Agora vamos alterar o nosso classCanvas.movePieceAtual para setar essa flag como verdadeira caso tudo dê certo e a peça se movimente. Vou adicionar como último comando do método.

// método pra mover a peça numa direção qualquer
    this.movePieceAtual = function(direcao) {
    	(...)
		// setando que essa peça conseguiu se mover pelo menos uma vez
		this.pieceAtual.seMoveu = true;
    };

Certo. Agora só precisamos verificar se a última peça se moveu na hora de criar uma peça nova. Se ela não se moveu, quer dizer que o jogador 'estourou' o grid em cima. Se isso acontecer, vamos finalizar o jogo.

// método para criar uma nova peça
	this.criaPiece = function() {
    	// caso já exista uma peça atual
    	if (this.pieceAtual) {
			// verifica se a peça atual se moveu pelo menos uma vez
			if (!this.pieceAtual.seMoveu) return this.finalizaJogo();
			(...)

Agora vou implementar esse método que finaliza o jogo, mas antes disso vou adicionar a inicialização de uma flag que vai gerenciar se o jogo está rodando ou não. Ela vai controlar se coisas podem ou não acontecer. Logo em seguida estou criando o classCanvas.finalizaJogo. Também adicionei alguns elementos no HTML/CSS pra mostrar o resultado.

// método para iniciar o jogo
	this.iniciar = function() {
		// define que o jogo está em andamento
		this.rodando = true;
		(...)
// método para finalizar o jogo
	this.finalizaJogo = function() {
		// primeiramente vamos paralizar o jogo
		this.rodando = false;
		// vamos cancelar o movimento-pra-baixo-automático
        clearInterval(this.intervalMovimento);
		// vamos mostrar uma mensagem de fim de jogo
		$('#status')[0].innerHTML = 'Fim de jogo!';		
	};
<div id='score'>
	Score:<br/>
	<span id='myscore'>000000</span>
	<div id='status'></div>
</div>
#status {
	font-size:50px;
}

Ok, por fim vou alterar o método que escuta as teclas para que ele não faça nada caso o jogo esteja rodando.

// método para 'ouvir' as teclas pressionadas
    this.adicionaControles = function() {
        document.body.addEventListener('keydown',function(e) {
			// se o jogo não estiver rodando, ignore
			if (!this.rodando) return;
			(...)

Eeee pronto!


EXTRA: A cada x pontos, o jogo deve ficar mais rápido!

Acharam que eu ia esquecer disso? Heheh não esqueci não! Então, vai funcionar assim: O jogo começa com uma intervalo de 750ms entra cada movimento automático das peças. Isto é, se você não fizer nada, a cada 750ms a peça vai descer uma linha (eu tinha falado pra começar com 1000ms, mas é muito lento meeeesmo, então desci pra 750). Eu vou alterar aqui pra, a cada 2500 pontos, esse valor diminua em 50ms, até chegar a um mínimo de 150ms... que é rápido pra caramba (sim, eu testei aqui lol).

Vou começar alterando a minha classCanvas.iniciar pra definir esse intervalo de pontuações e próxima pontuação que vai gerar a mudança de velocidade.

// método para iniciar o jogo
	this.iniciar = function() {
		// define que o jogo está em andamento
		this.rodando = true;
		// iniciando a pontuação
		this.pontos = 0;
		// iniciando um vetor pra guardar todas as peças do jogo
		this.pieces = [];
		// criando a primeira peça
		this.criaPiece();
		// começa a desenhar
		this.desenhaTudo();
        // seta o delay de movimento inicial (quanto menor, mais rápido)
        this.delayMovimento = 750;
		// definindo a qtde de pontos necessárias pra aumentar a velocidade
		this.pontosVelocidade = 2500;
		// definindo a próxima qtde de pontos onde a velocidade vai aumentar
		this.proximaPontuacao = this.pontosVelocidade;
        // inicia o novimento
        this.iniciaMovimento();
	};

Agora é só alterar a classCanvas.ganhaPontos pra conferir se a pontuação atual é maior que próxima quantidade necessária... e então aumentar a velocidade do jogo. Quando isso acontece, a gente aumenta o valor da próxima velocidade pra quando o jogador chegar no novo valor, tudo acontece de novo e assim por diante.

// método para adicionar pontos ao score
	this.ganhaPontos = function(qtde) {
		(...)	
		// caso já estejamos no menor delay possível, não vamos mais tentar diminuir
		if (this.delayMovimento <= 150) return;
		// verificando se já atingimos a próxima pontuação necessária
		while (this.pontos >= this.proximaPontuacao) {
			// se atingimos, vamos diminuir o delay (aumentar a velocidade do jogo)
			this.delayMovimento -= 50;
			// e vamos definir a nova próxima pontuação necesária
			this.proximaPontuacao += this.pontosVelocidade;
			// por último, vamos resetar o movimento pra ele usar a nova velocidade
			this.iniciaMovimento();
		}		
	};

Feito! (novamente, não se esqueçam que vocês podem clicar em 'Result' pra começar de novo)


E por aqui nós encerramos a terceira parte desse tutorial! Espero que tenham gostado, e que tenham aprendido algo com isso tudo! \o/

Leave a Comment