Projeto: Um Jogo de Plataforma

Grande parte da minha fascinação inicial com computadores, como a de muitas crianças nerds, tinha a ver com jogos de computador. Eu era atraído pelos pequenos mundos simulados que eu podia manipular e nos quais histórias (de certa forma) se desenrolavam — mais, suponho, por causa da forma como eu projetava minha imaginação neles do que pelas possibilidades que eles realmente ofereciam.
Eu não desejo uma carreira em programação de jogos para ninguém. Assim como na indústria da música, a discrepância entre o número de jovens entusiasmados querendo trabalhar nela e a demanda real por essas pessoas cria um ambiente bastante pouco saudável. Mas escrever jogos para diversão é divertido.
Este capítulo irá guiar pela implementação de um pequeno jogo de plataforma. Jogos de plataforma (ou “jump and run”) são jogos que esperam que o jogador mova uma figura através de um mundo, que geralmente é bidimensional e visto de lado, pulando sobre e em cima das coisas.
O jogo
Nosso jogo será baseado aproximadamente em Dark Blue por Thomas Palef. Eu escolhi esse jogo porque é divertido e minimalista e porque pode ser construído sem muito código. Ele é assim:

A caixa escura representa o jogador, cuja tarefa é coletar as caixas amarelas (moedas) enquanto evita o material vermelho (lava). Um nível é completado quando todas as moedas são coletadas.
O jogador pode andar com as setas esquerda e direita e pode pular com a seta para cima. Pular é a especialidade deste personagem de jogo. Ele pode alcançar várias vezes sua própria altura e pode mudar de direção no ar. Isso pode não ser totalmente realista, mas ajuda a dar ao jogador a sensação de estar no controle direto do avatar na tela.
O jogo consiste de um fundo estático, organizado como uma grade, com os elementos móveis sobrepostos a esse fundo. Cada campo na grade é vazio, sólido ou lava. Os elementos móveis são o jogador, moedas e certas partes de lava. As posições desses elementos não estão restritas à grade — suas coordenadas podem ser fracionárias, permitindo um movimento suave.
A tecnologia
Usaremos o DOM do navegador para exibir o jogo, e leremos a entrada do usuário tratando os eventos de tecla.
O código relacionado à tela e ao teclado é apenas uma pequena parte do trabalho que precisamos fazer para construir este jogo. Como tudo parece caixas coloridas, desenhar é simples: criamos elementos DOM e usamos estilo para dar a eles cor de fundo, tamanho e posição.
Podemos representar o fundo como uma tabela, já que é uma grade inalterada de quadrados. Os elementos livres para movimentar podem ser sobrepostos usando elementos posicionados absolutamente.
Em jogos e outros programas que devem animar gráficos e responder à entrada do usuário sem atraso perceptível, a eficiência é importante. Embora o DOM não tenha sido originalmente projetado para gráficos de alto desempenho, ele é na verdade melhor nisso do que você imagina. Você viu algumas animações no Capítulo 14. Em uma máquina moderna, um jogo simples assim funciona bem, mesmo se não nos preocuparmos demais com otimização.
No próximo capítulo, exploraremos outra tecnologia do navegador, a tag <canvas>, que fornece uma forma mais tradicional de desenhar gráficos, trabalhando em termos de formas e pixels em vez de elementos DOM.
Níveis
Queremos uma forma legível para humanos, editável por humanos, para especificar níveis. Como tudo pode começar em uma grade, poderíamos usar grandes strings nas quais cada caractere representa um elemento — seja uma parte da grade de fundo ou um elemento móvel.
O plano para um pequeno nível poderia ser assim:
let simpleLevelPlan = ` ...................... ..#................#.. ..#..............=.#.. ..#.........o.o....#.. ..#.@......#####...#.. ..#####............#.. ......#++++++++++++#.. ......##############.. ......................`;
Pontos representam espaço vazio, caracteres de cerquilha (#) são paredes, e sinais de mais representam lava. A posição inicial do player é o sinal de arroba (@). Cada caractere O é uma moeda, e o sinal de igual (=) no topo é um bloco de lava que se move para frente e para trás horizontalmente.
Suportaremos dois tipos adicionais de lava móvel: o caractere pipe (|) cria blobs que se movem verticalmente, e v indica lava pingando—lava que se move verticalmente sem quicar para trás, apenas descendo e voltando à posição inicial quando atinge o chão.
Um game completo consiste em múltiplos levels que o player deve terminar. Um nível é concluído quando todas as coins são coletadas. Se o jogador tocar na lava, o nível atual é restaurado para sua posição inicial, e o jogador pode tentar novamente.
Lendo um nível
A class a seguir armazena um objeto de level. Seu argumento deve ser a string que define o nível.
class Level { constructor(plan) { let rows = plan.trim().split("\n").map(l => [...l]); this.height = rows.length; this.width = rows[0].length; this.startActors = []; this.rows = rows.map((row, y) => { return row.map((ch, x) => { let type = levelChars[ch]; if (typeof type != "string") { let pos = new Vec(x, y); this.startActors.push(type.create(pos, ch)); type = "empty"; } return type; }); }); } }
O método trim é usado para remover espaços em branco do início e do fim da string do plano. Isso permite que nosso plano exemplo comece com uma quebra de linha para que todas as linhas fiquem diretamente uma abaixo da outra. A string restante é dividida em caractere de nova linhas, e cada linha é distribuída em um array, produzindo arrays de caracteres.
Assim, rows contém um array de arrays de caracteres, as linhas do plano. Podemos derivar a largura e a altura do nível a partir deles. Mas ainda precisamos separar os elementos móveis do grid de fundo. Chamaremos os elementos móveis de actors. Eles serão armazenados em um array de objetos. O fundo será um array de arrays de strings, contendo tipos de campo como "empty", "wall" ou "lava".
Para criar esses arrays, mapeamos sobre as linhas e depois sobre seu conteúdo. Lembre-se que map passa o índice do array como segundo argumento para a função de mapeamento, que nos informa as coordenadas x e y de um dado caractere. As posições no jogo serão armazenadas como pares de coordenadas, com o canto superior esquerdo sendo 0,0 e cada quadrado do fundo com 1 unidade de altura e largura.
Para interpretar os caracteres no plano, o construtor Level usa o objeto levelChars, que, para cada caractere usado nas descrições do nível, contém uma string se for um tipo de fundo, e uma classe se for para produzir um actor. Quando type é uma classe de actor, seu método estático create é usado para criar um objeto, que é adicionado a startActors, e a função de mapeamento retorna "empty" para esse quadrado do fundo.
A posição do actor é armazenada como um objeto Vec. Esse é um vetor bidimensional, um objeto com propriedades x e y, como visto nos exercícios do Capítulo 6.
À medida que o jogo roda, os actors acabam em lugares diferentes ou até desaparecem totalmente (como as moedas quando coletadas). Usaremos uma classe State para acompanhar o estado de um jogo em execução.
class State { constructor(level, actors, status) { this.level = level; this.actors = actors; this.status = status; } static start(level) { return new State(level, level.startActors, "playing"); } get player() { return this.actors.find(a => a.type == "player"); } }
A propriedade status mudará para "lost" ou "won" quando o jogo terminar.
Esta é novamente uma estrutura de dados persistente—atualizar o estado do jogo cria um novo estado e mantém o antigo intacto.
Actors
Objetos do tipo actor representam a posição atual e o estado de um dado elemento móvel (player, coin ou lava móvel) no nosso jogo. Todos os objetos actor seguem a mesma interface. Eles têm propriedades size e pos que guardam o tamanho e as coordenadas do canto superior-esquerdo do retângulo que representa esse actor, e um método update.
Este método update é usado para calcular seu novo estado e posição após um determinado intervalo de tempo. Ele simula o que o actor faz—movendo-se em resposta às setas direcionais para o player e indo e voltando para a lava—e retorna um novo objeto actor atualizado.
Uma propriedade type contém uma string que identifica o tipo do actor—"player", "coin" ou "lava". Isso é útil ao desenhar o jogo—a aparência do retângulo desenhado para um actor é baseada em seu tipo.
As classes de actor têm um método estático create que é usado pelo construtor Level para criar um actor a partir de um caractere no plano do nível. Ele recebe as coordenadas do caractere e o próprio caractere, que é necessário porque a classe Lava trata vários caracteres diferentes.
Esta é a classe Vec que usaremos para nossos valores bidimensionais, como a posição e o tamanho dos actors.
class Vec { constructor(x, y) { this.x = x; this.y = y; } plus(other) { return new Vec(this.x + other.x, this.y + other.y); } times(factor) { return new Vec(this.x * factor, this.y * factor); } }
O método times escala um vetor por um número dado. Será útil quando precisarmos multiplicar um vetor de velocidade por um intervalo de tempo para obter a distância percorrida durante esse tempo.
Os diferentes tipos de actors recebem suas próprias classes, pois seus comportamentos são muito diferentes. Vamos definir essas classes. Veremos seus métodos update mais adiante.
A classe player tem uma propriedade speed que armazena sua velocidade atual para simular momento e gravidade.
class Player { constructor(pos, speed) { this.pos = pos; this.speed = speed; } get type() { return "player"; } static create(pos) { return new Player(pos.plus(new Vec(0, -0.5)), new Vec(0, 0)); } } Player.prototype.size = new Vec(0.8, 1.5);
Como um jogador tem um metro e meio de altura, sua posição inicial é definida para estar meio quadrado acima da posição onde o caractere @ apareceu. Dessa forma, sua parte inferior fica alinhada com o fundo do quadrado onde apareceu.
A propriedade size é a mesma para todas as instâncias de Player, então a armazenamos no protótipo em vez de nas próprias instâncias. Poderíamos ter usado um getter como type, mas isso criaria e retornaria um novo objeto Vec toda vez que a propriedade fosse lida, o que seria um desperdício. (Strings, por serem imutáveis, não precisam ser recriadas toda vez que são avaliadas.)
Ao construir um ator Lava, precisamos inicializar o objeto de forma diferente dependendo do caractere em que ele se baseia. Lava dinâmica se move com sua velocidade atual até bater em um obstáculo. Nesse ponto, se ela tiver uma propriedade reset, voltará para sua posição inicial (gotejando). Se não tiver, ela inverterá sua velocidade e continuará na direção oposta (quicando).
O método create analisa o caractere que o construtor Level passa e cria o ator de lava apropriado.
class Lava { constructor(pos, speed, reset) { this.pos = pos; this.speed = speed; this.reset = reset; } get type() { return "lava"; } static create(pos, ch) { if (ch == "=") { return new Lava(pos, new Vec(2, 0)); } else if (ch == "|") { return new Lava(pos, new Vec(0, 2)); } else if (ch == "v") { return new Lava(pos, new Vec(0, 3), pos); } } } Lava.prototype.size = new Vec(1, 1);
Atores Coin são relativamente simples. Eles ficam basicamente parados em seu lugar. Mas, para animar um pouco o jogo, eles recebem um “balanço”, um leve movimento vertical de vai e vem. Para controlar isso, um objeto coin armazena uma posição base, bem como uma propriedade wobble que acompanha a fase do movimento oscilante. Juntos, esses determinam a posição real da moeda (armazenada na propriedade pos).
class Coin { constructor(pos, basePos, wobble) { this.pos = pos; this.basePos = basePos; this.wobble = wobble; } get type() { return "coin"; } static create(pos) { let basePos = pos.plus(new Vec(0.2, 0.1)); return new Coin(basePos, basePos, Math.random() * Math.PI * 2); } } Coin.prototype.size = new Vec(0.6, 0.6);
No Capítulo 14, vimos que Math.sin nos dá a coordenada y de um ponto em um círculo. Essa coordenada vai e volta num formato suave enquanto nos movemos ao redor do círculo, o que torna a função seno útil para modelar um movimento ondulante.
Para evitar uma situação onde todas as moedas se movem para cima e para baixo sincronizadas, a fase inicial de cada moeda é randomizada. O período da onda produzida por Math.sin, a largura de uma onda gerada, é 2π. Multiplicamos o valor retornado por Math.random por esse número para dar à moeda uma posição inicial aleatória na onda.
Agora podemos definir o objeto levelChars que mapeia os caracteres do plano para tipos de grade de fundo ou classes de atores.
const levelChars = { ".": "empty", "#": "wall", "+": "lava", "@": Player, "o": Coin, "=": Lava, "|": Lava, "v": Lava };
Isso nos fornece todas as partes necessárias para criar uma instância de Level.
let simpleLevel = new Level(simpleLevelPlan); console.log(`${simpleLevel.width} por ${simpleLevel.height}`); // → 22 por 9
A tarefa à frente é exibir esses níveis na tela e modelar o tempo e o movimento dentro deles.
Desenho
No próximo capítulo, vamos exibir o mesmo jogo de uma forma diferente. Para possibilitar isso, colocamos a lógica de desenho atrás de uma interface e a passamos para o jogo como um argumento. Dessa forma, podemos usar o mesmo programa do jogo com diferentes novos módulos de exibição.
Um objeto de exibição de jogo desenha um dado nível e estado. Passamos seu construtor para o jogo para permitir que ele seja substituído. A classe de exibição que definimos neste capítulo é chamada DOMDisplay porque usa elementos DOM para mostrar o nível.
Usaremos uma folha de estilo para definir as cores reais e outras propriedades fixas dos elementos que compõem o jogo. Também seria possível atribuir diretamente à propriedade style dos elementos quando os criamos, mas isso produziria programas mais verbosos.
A função auxiliar a seguir fornece uma maneira sucinta de criar um elemento e dar a ele alguns atributos e nós filhos:
function elt(name, attrs, ...children) { let dom = document.createElement(name); for (let attr of Object.keys(attrs)) { dom.setAttribute(attr, attrs[attr]); } for (let child of children) { dom.appendChild(child); } return dom; }
Uma exibição é criada dando a ela um elemento pai ao qual deve se anexar e um objeto nível.
class DOMDisplay { constructor(parent, level) { this.dom = elt("div", {class: "game"}, drawGrid(level)); this.actorLayer = null; parent.appendChild(this.dom); } clear() { this.dom.remove(); } }
A grade background do nível, que nunca muda, é desenhada uma vez. Os atores são redesenhados toda vez que a exibição é atualizada com um dado estado. A propriedade actorLayer será usada para controlar o elemento que contém os atores, para que possam ser facilmente removidos e substituídos.
Nossas coordenadas e tamanhos são controlados em unidades da grade, onde um tamanho ou distância de 1 significa um bloco da grade. Ao definir tamanhos em pixels, teremos que escalar essas coordenadas — tudo no jogo seria ridiculamente pequeno com um pixel por quadrado. A constante scale indica o número de pixels que uma única unidade ocupa na tela.
const scale = 20; function drawGrid(level) { return elt("table", { class: "background", style: `width: ${level.width * scale}px` }, ...level.rows.map(row => elt("tr", {style: `height: ${scale}px`}, ...row.map(type => elt("td", {class: type}))) )); }
A forma do elemento <table> corresponde bem à estrutura da propriedade rows do nível — cada linha da grade é transformada em uma linha da tabela (elemento <tr>). As strings na grade são usadas como nomes de classe para os elementos das células da tabela (<td>). O código usa o operador spread (três pontos) para passar arrays de nós filhos para elt como argumentos separados.
O seguinte CSS faz a tabela parecer com o fundo que queremos:
.background { background: rgb(52, 166, 251);
table-layout: fixed;
border-spacing: 0; }
.background td { padding: 0; }
.lava { background: rgb(255, 100, 100); }
.wall { background: white; }
Alguns destes (table-layout, border-spacing e padding) são usados para suprimir comportamentos padrão indesejados. Não queremos que o layout da tabela dependa do conteúdo de suas células, e não queremos espaço entre as células da tabela nem padding dentro delas.
A regra background define a cor de fundo. CSS permite que cores sejam especificadas tanto como palavras (white) quanto com um formato como rgb(R, G, B), onde os componentes vermelho, verde e azul da cor são separados em três números de 0 a 255. Em rgb(52, 166, 251), o componente vermelho é 52, verde é 166 e azul é 251. Como o componente azul é o maior, a cor resultante será azulada. Na regra .lava, o primeiro número (vermelho) é o maior.
Desenhamos cada ator criando um elemento DOM para ele e definindo sua posição e tamanho baseados nas propriedades do ator. Os valores devem ser multiplicados por scale para converter unidades do jogo em pixels.
function drawActors(actors) { return elt("div", {}, ...actors.map(actor => { let rect = elt("div", {class: `actor ${actor.type}`}); rect.style.width = `${actor.size.x * scale}px`; rect.style.height = `${actor.size.y * scale}px`; rect.style.left = `${actor.pos.x * scale}px`; rect.style.top = `${actor.pos.y * scale}px`; return rect; })); }
Para dar a um elemento mais de uma classe, separamos os nomes das classes por espaços. No código CSS a seguir, a classe actor dá aos atores sua posição absoluta. O nome do tipo deles é usado como uma classe extra para dar uma cor. Não precisamos definir a classe lava novamente porque estamos reutilizando a classe para as células de lava que definimos antes.
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
O método syncState é usado para fazer a exibição mostrar um dado estado. Ele primeiro remove os gráficos antigos dos atores, se houver, e então redesenha os atores em suas novas posições. Pode ser tentador tentar reutilizar os elementos DOM para os atores, mas para fazer isso funcionar, precisaríamos de muito controle adicional para associar atores aos elementos DOM e garantir que removamos elementos quando seus atores desaparecem. Como normalmente haverá apenas alguns atores no jogo, redesenhar todos eles não é custoso.
DOMDisplay.prototype.syncState = function(state) { if (this.actorLayer) this.actorLayer.remove(); this.actorLayer = drawActors(state.actors); this.dom.appendChild(this.actorLayer); this.dom.className = `game ${state.status}`; this.scrollPlayerIntoView(state); };
Adicionando o status atual do nível como nome de classe ao container, podemos estilizar o ator jogador de forma ligeiramente diferente quando o jogo é ganho ou perdido adicionando uma regra CSS que só tem efeito quando o jogador tem um elemento ancestral com uma classe específica.
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
Depois de tocar na lava, o jogador fica vermelho escuro, sugerindo queimadura. Quando a última moeda é coletada, adicionamos duas sombras brancas esmaecidas — uma no canto superior esquerdo e outra no canto superior direito — para criar um efeito de halo branco.
Não podemos assumir que o nível sempre cabe no viewport, o elemento onde desenhamos o jogo. É por isso que precisamos da chamada scrollPlayerIntoView: ela garante que, se o nível estiver saindo do viewport, rolamos esse viewport para garantir que o jogador fique próximo do seu centro. O seguinte CSS dá ao elemento DOM que envolve o jogo um tamanho máximo e garante que qualquer coisa que ultrapasse a caixa do elemento não fique visível. Também damos a ele uma posição relativa para que os atores dentro dele sejam posicionados em relação ao canto superior esquerdo do nível.
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
No método scrollPlayerIntoView, encontramos a posição do jogador e atualizamos a posição de scroll do elemento que envolve o jogo. Mudamos a posição do scroll manipulando as propriedades scrollLeft e scrollTop desse elemento quando o jogador está muito próximo da borda.
DOMDisplay.prototype.scrollPlayerIntoView = function(state) { let width = this.dom.clientWidth; let height = this.dom.clientHeight; let margin = width / 3; // The viewport let left = this.dom.scrollLeft, right = left + width; let top = this.dom.scrollTop, bottom = top + height; let player = state.player; let center = player.pos.plus(player.size.times(0.5)) .times(scale); if (center.x < left + margin) { this.dom.scrollLeft = center.x - margin; } else if (center.x > right - margin) { this.dom.scrollLeft = center.x + margin - width; } if (center.y < top + margin) { this.dom.scrollTop = center.y - margin; } else if (center.y > bottom - margin) { this.dom.scrollTop = center.y + margin - height; } };
A forma como o centro do jogador é encontrado mostra como os métodos no nosso tipo Vec permitem que cálculos com objetos sejam escritos de uma maneira relativamente legível. Para encontrar o centro do ator, somamos sua posição (seu canto superior esquerdo) e metade do seu tamanho. Esse é o centro em coordenadas do nível, mas precisamos dele em coordenadas de pixel, então multiplicamos o vetor resultante pela escala do nosso display.
Em seguida, uma série de verificações garante que a posição do jogador não esteja fora do intervalo permitido. Note que às vezes isso vai definir coordenadas de scroll sem sentido, abaixo de zero ou além da área rolável do elemento. Isso é aceitável — o DOM vai restringi-las a valores aceitáveis. Definir scrollLeft para -10 fará com que ela se torne 0.
Embora fosse um pouco mais simples tentar sempre rolar o jogador para o centro do viewport, isso cria um efeito bastante incômodo. Enquanto você está pulando, a visão estará constantemente se movendo para cima e para baixo. É mais agradável ter uma área “neutra” no meio da tela onde você pode se mover sem causar nenhum scroll.
Agora somos capazes de exibir nosso pequeno nível.
<link rel="stylesheet" href="css/game.css"> <script> let simpleLevel = new Level(simpleLevelPlan); let display = new DOMDisplay(document.body, simpleLevel); display.syncState(State.start(simpleLevel)); </script>
A tag <link>, quando usada com rel="stylesheet", é uma forma de carregar um arquivo CSS em uma página. O arquivo game.css contém os estilos necessários para o nosso jogo.
Movimento e colisão
Agora chegamos ao ponto onde podemos começar a adicionar movimento. A abordagem básica adotada pela maioria dos jogos assim é dividir o tempo em pequenos passos e, para cada passo, mover os atores por uma distância correspondente à sua velocidade multiplicada pelo tamanho do passo de tempo. Mediremos o tempo em segundos, então as velocidades são expressas em unidades por segundo.
Mover coisas é fácil. A parte difícil é lidar com as interações entre os elementos. Quando o jogador bate em uma parede ou no chão, ele não deve simplesmente atravessá-los. O jogo deve perceber quando um determinado movimento faz um objeto bater em outro e responder adequadamente. Para paredes, o movimento deve ser interrompido. Ao bater em uma moeda, essa moeda deve ser coletada. Ao tocar lava, o jogo deve ser perdido.
Resolver isso para o caso geral é uma tarefa enorme. Você pode encontrar bibliotecas, geralmente chamadas de mecanismos de física, que simulam a interação entre objetos físicos em duas ou três dimensões. Adotaremos uma abordagem mais modesta neste capítulo, lidando apenas com colisões entre objetos retangulares e de uma forma bastante simplista.
Antes de mover o jogador ou um bloco de lava, testamos se o movimento o levaria para dentro de uma parede. Se levar, simplesmente cancelamos o movimento por completo. A resposta a tal colisão depende do tipo de ator—o jogador vai parar, enquanto um bloco de lava vai ricochetear para trás.
Essa abordagem requer que nossos passos de tempo sejam bem pequenos, pois isso fará com que o movimento pare antes que os objetos realmente se toquem. Se os passos de tempo (e, portanto, os passos de movimento) forem muito grandes, o jogador acabaria flutuando a uma distância perceptível do chão. Outra abordagem, possivelmente melhor mas mais complicada, seria encontrar o ponto exato da colisão e mover até lá. Trabalharemos com a abordagem simples e esconderemos seus problemas garantindo que a animação prossiga em pequenos passos.
Esse método nos diz se um retângulo (especificado por uma posição e um tamanho) toca um elemento da grade do tipo dado.
Level.prototype.touches = function(pos, size, type) { let xStart = Math.floor(pos.x); let xEnd = Math.ceil(pos.x + size.x); let yStart = Math.floor(pos.y); let yEnd = Math.ceil(pos.y + size.y); for (let y = yStart; y < yEnd; y++) { for (let x = xStart; x < xEnd; x++) { let isOutside = x < 0 || x >= this.width || y < 0 || y >= this.height; let here = isOutside ? "wall" : this.rows[y][x]; if (here == type) return true; } } return false; };
O método calcula o conjunto de quadrados da grade com os quais o corpo se sobrepõe usando Math.floor e Math.ceil em suas coordenadas. Lembre-se que os quadrados da grade têm tamanho 1 por 1 unidade. Ao arredondar os lados de uma caixa para cima e para baixo, obtemos o intervalo de quadrados de fundo que a caixa toca.
Percorremos o bloco de quadrados da grade encontrado por arredondar as coordenadas e retornamos true quando um quadrado correspondente é encontrado. Quadrados fora do nível são sempre tratados como "wall" para garantir que o jogador não possa sair do mundo e que não tentemos acidentalmente ler fora dos limites do nosso array rows.
O método update do estado usa touches para descobrir se o jogador está tocando lava.
State.prototype.update = function(time, keys) { let actors = this.actors .map(actor => actor.update(time, this, keys)); let newState = new State(this.level, actors, this.status); if (newState.status != "playing") return newState; let player = newState.player; if (this.level.touches(player.pos, player.size, "lava")) { return new State(this.level, actors, "lost"); } for (let actor of actors) { if (actor != player && overlap(actor, player)) { newState = actor.collide(newState); } } return newState; };
O método recebe um passo de tempo e uma estrutura de dados que informa quais teclas estão pressionadas. A primeira coisa que ele faz é chamar o método update em todos os atores, produzindo um array de atores atualizados. Os atores também recebem o passo de tempo, as teclas e o estado para que possam basear sua atualização nisso. Apenas o jogador realmente lerá as teclas, já que é o único ator controlado pelo teclado. Se o jogo já acabou, nenhum processamento adicional precisa ser feito (o jogo não pode ser ganho depois de perdido, ou vice-versa). Caso contrário, o método verifica se o jogador está tocando lava do fundo. Se estiver, o jogo é perdido e terminamos. Finalmente, se o jogo realmente ainda estiver em andamento, ele verifica se algum outro ator está sobreposto ao jogador.
A sobreposição entre atores é detectada pela função overlap. Ela recebe dois objetos ator e retorna true quando eles se tocam — o que acontece quando eles se sobrepõem tanto no eixo x quanto no eixo y.
function overlap(actor1, actor2) { return actor1.pos.x + actor1.size.x > actor2.pos.x && actor1.pos.x < actor2.pos.x + actor2.size.x && actor1.pos.y + actor1.size.y > actor2.pos.y && actor1.pos.y < actor2.pos.y + actor2.size.y; }
Se algum ator se sobrepuser, seu método collide tem a chance de atualizar o estado. Tocar em um ator de lava define o status do jogo para "lost". Moedas desaparecem quando são tocadas e definem o status para "won" quando são a última moeda do nível.
Lava.prototype.collide = function(state) { return new State(state.level, state.actors, "lost"); }; Coin.prototype.collide = function(state) { let filtered = state.actors.filter(a => a != this); let status = state.status; if (!filtered.some(a => a.type == "coin")) status = "won"; return new State(state.level, filtered, status); };
Atualizações de ator
Os métodos update dos objetos ator recebem como argumentos o passo de tempo, o objeto estado e um objeto keys. O método para o tipo de ator Lava ignora o objeto keys.
Lava.prototype.update = function(time, state) { let newPos = this.pos.plus(this.speed.times(time)); if (!state.level.touches(newPos, this.size, "wall")) { return new Lava(newPos, this.speed, this.reset); } else if (this.reset) { return new Lava(this.reset, this.speed, this.reset); } else { return new Lava(this.pos, this.speed.times(-1)); } };
Esse método update calcula uma nova posição somando o produto do passo de tempo pela velocidade atual à sua posição anterior. Se nenhum obstáculo bloquear essa nova posição, ele se move para lá. Se houver um obstáculo, o comportamento depende do tipo do bloco de lava — a lava pingando tem uma posição reset, para a qual ela salta de volta ao bater em algo. A lava quicando inverte sua velocidade multiplicando-a por -1, assim ela começa a se mover para a direção oposta.
Moedas usam seu método update para oscilar. Elas ignoram colisões com a grade, já que estão simplesmente oscilando dentro de sua própria área.
const wobbleSpeed = 8, wobbleDist = 0.07; Coin.prototype.update = function(time) { let wobble = this.wobble + time * wobbleSpeed; let wobblePos = Math.sin(wobble) * wobbleDist; return new Coin(this.basePos.plus(new Vec(0, wobblePos)), this.basePos, wobble); };
A propriedade wobble é incrementada para acompanhar o tempo e depois usada como argumento para Math.sin para encontrar a nova posição na onda. A posição atual da moeda é então calculada a partir da sua posição base e um deslocamento baseado nessa onda.
Isso deixa o próprio jogador. O movimento do jogador é tratado separadamente por eixo porque bater no chão não deveria impedir o movimento horizontal, e bater em uma parede não deveria parar o movimento de cair ou pular.
const playerXSpeed = 7; const gravity = 30; const jumpSpeed = 17; Player.prototype.update = function(time, state, keys) { let xSpeed = 0; if (keys.ArrowLeft) xSpeed -= playerXSpeed; if (keys.ArrowRight) xSpeed += playerXSpeed; let pos = this.pos; let movedX = pos.plus(new Vec(xSpeed * time, 0)); if (!state.level.touches(movedX, this.size, "wall")) { pos = movedX; } let ySpeed = this.speed.y + time * gravity; let movedY = pos.plus(new Vec(0, ySpeed * time)); if (!state.level.touches(movedY, this.size, "wall")) { pos = movedY; } else if (keys.ArrowUp && ySpeed > 0) { ySpeed = -jumpSpeed; } else { ySpeed = 0; } return new Player(pos, new Vec(xSpeed, ySpeed)); };
O movimento horizontal é calculado com base no estado das setas esquerda e direita. Quando não há uma parede bloqueando a nova posição criada por esse movimento, ela é usada. Caso contrário, a posição antiga é mantida.
O movimento vertical funciona de forma semelhante, mas precisa simular pulo e gravidade. A velocidade vertical do jogador (ySpeed) é primeiro acelerada para considerar a gravidade.
Verificamos as paredes novamente. Se não batermos em nenhuma, a nova posição é usada. Se houver uma parede, há dois resultados possíveis. Quando a seta para cima está pressionada e estamos nos movendo para baixo (ou seja, o que atingimos está abaixo de nós), a velocidade é definida para um valor negativo relativamente alto. Isso faz o jogador pular. Se esse não for o caso, o jogador simplesmente bateu em algo, e a velocidade é zerada.
A força da gravidade, a velocidade do pulo e outras constantes no jogo foram determinadas simplesmente testando alguns números e vendo quais soavam melhor. Você pode experimentar com eles.
Rastreando teclas
Para um game como este, não queremos que as teclas tenham efeito apenas uma vez por pressionamento. Queremos que o efeito delas (mover a figura do jogador) permaneça ativo enquanto estiverem seguradas.
Precisamos configurar um manipulador de teclas que armazene o estado atual das setas esquerda, direita e para cima. Também queremos chamar preventDefault para essas teclas para que elas não acabem rolando a página.
A seguinte função, quando recebe um array de nomes de teclas, retorna um objeto que rastreia o estado atual dessas teclas. Ela registra manipuladores para os eventos "keydown" e "keyup" e, quando o código da tecla no evento está presente no conjunto de códigos que está rastreando, atualiza o objeto.
function trackKeys(keys) { let down = Object.create(null); function track(event) { if (keys.includes(event.key)) { down[event.key] = event.type == "keydown"; event.preventDefault(); } } window.addEventListener("keydown", track); window.addEventListener("keyup", track); return down; } const arrowKeys = trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);
A mesma função manipuladora é usada para ambos os tipos de evento. Ela verifica a propriedade type do objeto evento para determinar se o estado da tecla deve ser atualizado para verdadeiro ("keydown") ou falso ("keyup").
Executando o jogo
A função requestAnimationFrame, que vimos no Capítulo 14, oferece uma boa maneira de animar um jogo. Mas sua interface é bem primitiva — usar essa função exige que acompanhemos o momento em que nossa função foi chamada na última vez e chamemos requestAnimationFrame novamente após cada frame.
Vamos definir uma função auxiliar que abstrai tudo isso em uma interface conveniente e nos permite simplesmente chamar runAnimation, dando a ela uma função que espera uma diferença de tempo como argumento e desenha um único frame. Quando a função do frame retorna o valor false, a animação para.
function runAnimation(frameFunc) { let lastTime = null; function frame(time) { if (lastTime != null) { let timeStep = Math.min(time - lastTime, 100) / 1000; if (frameFunc(timeStep) === false) return; } lastTime = time; requestAnimationFrame(frame); } requestAnimationFrame(frame); }
Eu defini um passo máximo do frame de 100 milissegundos (um décimo de segundo). Quando a aba ou janela do navegador com nossa página está oculta, as chamadas de requestAnimationFrame são suspensas até que a aba ou janela sejam mostradas novamente. Nesse caso, a diferença entre lastTime e time será todo o tempo em que a página ficou oculta. Avançar o jogo tanto em um único passo pareceria estranho e poderia causar efeitos colaterais esquisitos, como o jogador atravessar o chão.
A função também converte os passos de tempo em segundos, que são uma quantidade mais fácil de pensar do que milissegundos.
A função runLevel recebe um objeto Level e um construtor _display_ e retorna uma promise. Ela exibe o nível (em document.body) e permite que o usuário jogue. Quando o nível termina (perdido ou ganho), runLevel espera mais um segundo (para que o usuário veja o que aconteceu), depois limpa o display, para a animação e resolve a promise com o status final do jogo.
function runLevel(level, Display) { let display = new Display(document.body, level); let state = State.start(level); let ending = 1; return new Promise(resolve => { runAnimation(time => { state = state.update(time, arrowKeys); display.syncState(state); if (state.status == "playing") { return true; } else if (ending > 0) { ending -= time; return true; } else { display.clear(); resolve(state.status); return false; } }); }); }
Um jogo é uma sequência de níveis. Sempre que o jogador morre, o nível atual é reiniciado. Quando um nível é completado, avançamos para o próximo nível. Isso pode ser expresso pela função abaixo, que recebe um array de planos de níveis (strings) e um construtor de _display_:
async function runGame(plans, Display) { for (let level = 0; level < plans.length;) { let status = await runLevel(new Level(plans[level]), Display); if (status == "won") level++; } console.log("Você venceu!"); }
Como fizemos runLevel retornar uma promise, runGame pode ser escrita usando uma função async, como mostrado no Capítulo 11. Ela retorna outra promise, que é resolvida quando o jogador termina o jogo.
Existe um conjunto de planos de level disponíveis na variável GAME_LEVELS no sandbox deste capítulo. Esta página os envia para runGame, iniciando um jogo real.
<link rel="stylesheet" href="css/game.css"> <body> <script> runGame(GAME_LEVELS, DOMDisplay); </script> </body>
Veja se você consegue vencê-los. Eu me diverti criando-os.
Exercícios
Fim de jogo
É tradicional que platform games comecem com um número limitado de vidas e que se subtraia uma vida a cada morte. Quando o jogador ficar sem vidas, o jogo reinicia desde o começo.
Ajuste runGame para implementar vidas. Faça o jogador começar com três. Exiba o número atual de vidas (usando console.log) toda vez que um nível começar.
<link rel="stylesheet" href="css/game.css"> <body> <script> // A antiga função runGame. Modifique-a... async function runGame(plans, Display) { for (let level = 0; level < plans.length;) { let status = await runLevel(new Level(plans[level]), Display); if (status == "won") level++; } console.log("Você venceu!"); } runGame(GAME_LEVELS, DOMDisplay); </script> </body>
Pausando o jogo
Faça com que seja possível pausar (suspender) e despausar o jogo ao pressionar esc. Você pode fazer isso mudando a função runLevel para configurar um manipulador de eventos de teclado que interrompa ou retome a animação sempre que a tecla esc for pressionada.
A interface runAnimation pode não parecer adequada para isso à primeira vista, mas é, se você rearranjar a forma como runLevel a chama.
Quando isso estiver funcionando, há outra coisa que você pode tentar. A forma como temos registrado os manipuladores de eventos de teclado é um pouco problemática. O objeto arrowKeys atualmente é uma variável global, e seus manipuladores de eventos permanecem ativos mesmo quando nenhum jogo está rodando. Você poderia dizer que eles vazam do nosso sistema. Estenda trackKeys para fornecer uma forma de cancelar o registro dos seus manipuladores, depois mude runLevel para registrar seus manipuladores quando começar e para cancelar o registro quando terminar.
<link rel="stylesheet" href="css/game.css"> <body> <script> // A antiga função runLevel. Modifique isto... function runLevel(level, Display) { let display = new Display(document.body, level); let state = State.start(level); let ending = 1; return new Promise(resolve => { runAnimation(time => { state = state.update(time, arrowKeys); display.syncState(state); if (state.status == "playing") { return true; } else if (ending > 0) { ending -= time; return true; } else { display.clear(); resolve(state.status); return false; } }); }); } runGame(GAME_LEVELS, DOMDisplay); </script> </body>
Mostrar dicas...
Uma animação pode ser interrompida retornando false da função dada para runAnimation. Ela pode continuar sendo executada chamando runAnimation novamente.
Então precisamos comunicar o fato de que estamos pausando o jogo para a função dada a runAnimation. Para isso, você pode usar uma ligação ao qual tanto o manipulador de evento quanto essa função tenham acesso.
Ao encontrar uma forma de desregistrar os manipuladores registrados por trackKeys, lembre-se que a mesma exata função que foi passada para addEventListener deve ser passada para removeEventListener para remover com sucesso um manipulador. Portanto, o valor da função handler criado em trackKeys deve estar disponível para o código que desregistra os manipuladores.
Você pode adicionar uma propriedade ao objeto retornado por trackKeys, contendo ou esse valor de função ou um método que lide diretamente com o desregistro.
Um monstro
É tradicional que games de plataforma tenham inimigos que você pode derrotar pulando em cima deles. Este exercício te pede para adicionar esse tipo de ator ao jogo.
Chamaremos esse ator de monstro. Monstros se movem apenas horizontalmente. Você pode fazê-los se mover na direção do jogador, quicar para frente e para trás como lava horizontal, ou qualquer outro padrão de movimento que você quiser. A classe não precisa lidar com quedas, mas deve garantir que o monstro não caminhe através de paredes.
Quando um monstro toca o jogador, o efeito depende de o jogador estar pulando em cima dele ou não. Você pode aproximar isso verificando se a parte de baixo do jogador está perto do topo do monstro. Se esse for o caso, o monstro desaparece. Se não, o jogo é perdido.
<link rel="stylesheet" href="css/game.css"> <style>.monster { background: purple }</style> <body> <script> // Complete o construtor, update e os métodos collide class Monster { constructor(pos, /* ... */) {} get type() { return "monster"; } static create(pos) { return new Monster(pos.plus(new Vec(0, -1))); } update(time, state) {} collide(state) {} } Monster.prototype.size = new Vec(1.2, 2); levelChars["M"] = Monster; runLevel(new Level(` .................................. .################################. .#..............................#. .#..............................#. .#..............................#. .#...........................o..#. .#..@...........................#. .##########..............########. ..........#..o..o..o..o..#........ ..........#...........M..#........ ..........################........ .................................. `), DOMDisplay); </script> </body>
Mostrar dicas...
Se você quiser implementar um tipo de movimento que tenha estado, como quicar, certifique-se de armazenar o estado necessário no objeto ator—inclua-o como argumento do construtor e adicione-o como propriedade.
Lembre-se de que update retorna um objeto novo em vez de alterar o antigo.
Ao lidar com colisão, encontre o jogador em state.actors e compare sua posição com a do monstro. Para obter o fundo do jogador, você precisa somar seu tamanho vertical à sua posição vertical. A criação de um estado atualizado se parecerá com o método collide do Coin (removendo o ator) ou do Lava (alterando o status para "lost"), dependendo da posição do jogador.