Criando um jogo de corrida pseudo-3D: implementando as colinas e finalizando o jogo

Parte 3. Colinas



Na parte anterior, criamos um jogo de corrida pseudo-tridimensional simples, realizando estradas retas e curvas nele.

Desta vez, cuidaremos das colinas; felizmente, será muito mais fácil do que criar estradas curvas.

Na primeira parte, usamos a lei de triângulos semelhantes para criar uma projeção em perspectiva tridimensional:


... o que nos levou a obter as equações para projetar as coordenadas do mundo 3d nas coordenadas da tela 2d.


... mas desde então trabalhamos apenas com estradas retas, as coordenadas do mundo precisavam apenas do componente z , porque ambos x e y eram iguais a zero.

Isso nos convém bem, porque para adicionar colinas, basta fornecer aos segmentos de estrada a coordenada diferente de zero y , após o qual a função existente funcionará render()magicamente.


Sim, é o suficiente para subir as colinas. Basta adicionar o componente y às coordenadas mundiais de cada segmento de estrada .

Alterações na geometria da estrada


Modificaremos o método existente addSegmentpara que a função que o chama possa passar p2.world.y e p1.world.y corresponda a p2.world.y do segmento anterior:

function addSegment(curve, y) {
  var n = segments.length;
  segments.push({
     index: n,
        p1: { world: { y: lastY(), z:  n   *segmentLength }, camera: {}, screen: {} },
        p2: { world: { y: y,       z: (n+1)*segmentLength }, camera: {}, screen: {} },
     curve: curve,
     color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
  });
}

function lastY() {
  return (segments.length == 0) ? 0 : segments[segments.length-1].p2.world.y;
}

Adicione constantes para indicar colinas baixas ( LOW), médias ( MEDIUM) e altas ( HIGH):

var ROAD = {
  LENGTH: { NONE: 0, SHORT:  25, MEDIUM:  50, LONG:  100 },
  HILL:   { NONE: 0, LOW:    20, MEDIUM:  40, HIGH:   60 },
  CURVE:  { NONE: 0, EASY:    2, MEDIUM:   4, HARD:    6 }
};

Altere o método existente addRoad()para que ele receba o argumento y , que será usado junto com as funções de suavidade para a subida e descida gradual da colina:

function addRoad(enter, hold, leave, curve, y) {
  var startY   = lastY();
  var endY     = startY + (Util.toInt(y, 0) * segmentLength);
  var n, total = enter + hold + leave;
  for(n = 0 ; n < enter ; n++)
    addSegment(Util.easeIn(0, curve, n/enter), Util.easeInOut(startY, endY, n/total));
  for(n = 0 ; n < hold  ; n++)
    addSegment(curve, Util.easeInOut(startY, endY, (enter+n)/total));
  for(n = 0 ; n < leave ; n++)
    addSegment(Util.easeInOut(curve, 0, n/leave), Util.easeInOut(startY, endY, (enter+hold+n)/total));
}

Além disso, semelhante ao que fizemos na parte 2 s addSCurves(), podemos impor quaisquer métodos necessários para construir a geometria, por exemplo:

function addLowRollingHills(num, height) {
  num    = num    || ROAD.LENGTH.SHORT;
  height = height || ROAD.HILL.LOW;
  addRoad(num, num, num,  0,  height/2);
  addRoad(num, num, num,  0, -height);
  addRoad(num, num, num,  0,  height);
  addRoad(num, num, num,  0,  0);
  addRoad(num, num, num,  0,  height/2);
  addRoad(num, num, num,  0,  0);
}

Alterações no método de atualização


No jogo de arcade que estamos criando, não tentaremos simular a realidade, de modo que as colinas não afetam o jogador ou o mundo do jogo de forma alguma, o que significa que update()mudanças não são necessárias no método .

Renderização em colina


render()Também não são necessárias alterações no método , porque as equações de projeção foram originalmente escritas para projetar corretamente os segmentos da estrada com coordenadas y diferentes de zero .

Fundo de rolagem de paralaxe


Além de adicionar coordenadas y a todos os segmentos da estrada , a única alteração será a implementação do deslocamento vertical das camadas de fundo junto com as colinas (assim como elas se movem horizontalmente junto com as curvas). Implementamos isso com outro argumento para a função auxiliar Render.background.

O mecanismo mais simples será o deslocamento de fundo usual em relação à posição playerY(que deve ser interpolada das posições mundiais y do segmento de jogador atual).

Esse não é o comportamento mais realista, porque provavelmente vale a pena considerar a inclinação do segmento atual da estrada do jogador, mas esse efeito é simples e funciona muito bem para uma demonstração simples.

Conclusão


Isso é tudo, agora podemos complementar as curvas falsas com colinas reais:


O trabalho realizado por nós na primeira parte, incluindo a infraestrutura para adicionar colinas 3D reais projetadas, eu apenas não falei sobre isso antes.

Na última parte do artigo, adicionaremos sprites, além de árvores e outdoors nas bordas da estrada. Também adicionaremos outros carros contra os quais será possível competir, reconhecimento de colisões e fixação do "registro circular" do jogador.

Parte 4. Versão Pronta



Nesta parte, adicionaremos:

  • Outdoors e árvores
  • Outros carros
  • Reconhecimento de colisão
  • IA rudimentar de carros
  • Interface com contador de voltas e registro de voltas

... e isso nos proporcionará um nível suficiente de interatividade para finalmente chamar nosso projeto de "jogo".

Nota sobre a estrutura do código


, /, Javascript.

. () , ...

… , , , , .



Na parte 1, antes do início do ciclo do jogo, fizemos o upload de uma folha de sprite contendo todos os carros, árvores e outdoors.

Você pode criar manualmente uma folha de sprite em qualquer editor de imagens, mas é melhor confiar o armazenamento de imagens e o cálculo de coordenadas a uma ferramenta automatizada. No meu caso, a folha de sprite foi gerada por uma pequena tarefa Rake usando a fábrica de sprites Ruby Gem .

Esta tarefa gera planilhas combinadas a partir de arquivos de imagem separados e também calcula as coordenadas x, y, w, h, que serão armazenadas em uma constante SPRITES:

var SPRITES = {
  PALM_TREE:   { x:    5, y:    5, w:  215, h:  540 },
  BILLBOARD08: { x:  230, y:    5, w:  385, h:  265 },

  // ... etc

  CAR04:       { x: 1383, y:  894, w:   80, h:   57 },
  CAR01:       { x: 1205, y: 1018, w:   80, h:   56 },
};

Adicionando outdoors e árvores


Adicione a cada segmento da estrada uma matriz que conterá sprites de objetos ao longo das margens da estrada.

Cada sprite consiste no que sourcefoi retirado da coleção SPRITES, juntamente com um deslocamento horizontal offset, que é normalizado para que -1 indique a borda esquerda da estrada e +1 significa a borda direita, o que nos permite não depender do valor roadWidth.

Alguns sprites são colocados intencionalmente, outros são randomizados.

function addSegment() {
  segments.push({
    ...
    sprites: [],
    ...
  });
}

function addSprite(n, sprite, offset) {
  segments[n].sprites.push({ source: sprite, offset: offset });
}

function resetSprites() {

  addSprite(20,  SPRITES.BILLBOARD07, -1);
  addSprite(40,  SPRITES.BILLBOARD06, -1);
  addSprite(60,  SPRITES.BILLBOARD08, -1);
  addSprite(80,  SPRITES.BILLBOARD09, -1);
  addSprite(100, SPRITES.BILLBOARD01, -1);
  addSprite(120, SPRITES.BILLBOARD02, -1);
  addSprite(140, SPRITES.BILLBOARD03, -1);
  addSprite(160, SPRITES.BILLBOARD04, -1);
  addSprite(180, SPRITES.BILLBOARD05, -1);

  addSprite(240, SPRITES.BILLBOARD07, -1.2);
  addSprite(240, SPRITES.BILLBOARD06,  1.2);

  
  for(n = 250 ; n < 1000 ; n += 5) {
    addSprite(n, SPRITES.COLUMN, 1.1);
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE1, -1 - (Math.random() * 2));
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE2, -1 - (Math.random() * 2));
  }

  ...
}

Nota: se estivéssemos criando um jogo real, poderíamos escrever um editor de estradas para criar visualmente um mapa com colinas e curvas, além de adicionar um mecanismo para organizar sprites ao longo da estrada ... mas para nossas tarefas, podemos fazê-lo programaticamente addSprite().

Adicionando máquinas


Além de sprites de objetos nas margens da estrada, adicionaremos uma coleção de carros que ocuparão cada segmento, além de uma coleção separada de todos os carros na estrada.

var cars      = [];  // array of cars on the road
var totalCars = 200; // total number of cars on the road

function addSegment() {
  segments.push({
    ...
    cars: [], // array of cars within this segment
    ...
  });
}

O armazenamento de duas estruturas de dados de automóveis nos permite percorrer facilmente todos os carros de maneira iterativa update(), movendo-os de um segmento para outro, se necessário; ao mesmo tempo, isso nos permite executar render()apenas máquinas em segmentos visíveis.

Cada máquina recebe um deslocamento horizontal aleatório, posição z, fonte e velocidade do sprite:

function resetCars() {
  cars = [];
  var n, car, segment, offset, z, sprite, speed;
  for (var n = 0 ; n < totalCars ; n++) {
    offset = Math.random() * Util.randomChoice([-0.8, 0.8]);
    z      = Math.floor(Math.random() * segments.length) * segmentLength;
    sprite = Util.randomChoice(SPRITES.CARS);
    speed  = maxSpeed/4 + Math.random() * maxSpeed/(sprite == SPRITES.SEMI ? 4 : 2);
    car = { offset: offset, z: z, sprite: sprite, speed: speed };
    segment = findSegment(car.z);
    segment.cars.push(car);
    cars.push(car);
  }
}

Renderização em subida (retorno)


Nas partes anteriores, eu falei sobre renderizar segmentos da estrada, incluindo curvas e colinas, mas havia algumas linhas de código nelas que eu não considerava. Eles diziam respeito a uma variável maxycomeçando na parte inferior da tela, mas diminuindo ao renderizar cada segmento para determinar qual parte da tela já havia renderizado:

for(n = 0 ; n < drawDistance ; n++) {

  ...

  if ((segment.p1.camera.z <= cameraDepth) || // behind us
      (segment.p2.screen.y >= maxy))          // clip by (already rendered) segment
    continue;

  ...

  maxy = segment.p2.screen.y;
}

Isso nos permitirá cortar segmentos que serão cobertos por colinas já renderizadas.

No algoritmo tradicional do artista, a renderização geralmente acontece de trás para a frente, enquanto segmentos mais próximos se sobrepõem aos mais distantes. No entanto, não podemos gastar tempo processando polígonos, que serão substituídos, portanto, fica mais fácil renderizar da frente para trás e cortar segmentos distantes cobertos por segmentos já renderizados próximos se as coordenadas projetadas forem menores maxy.

Renderização de outdoors, árvores e carros


No entanto, o deslocamento iterativo de segmentos de estrada da frente para trás não funcionará ao renderizar sprites, porque eles geralmente se sobrepõem e, portanto, devem ser renderizados usando o algoritmo do artista.

Isso complica nosso método render()e nos obriga a ignorar segmentos de estrada em dois estágios:

  1. frente para trás para renderização de estradas
  2. retroceder para renderizar sprites


Além de sprites parcialmente sobrepostos, precisamos lidar com sprites que “se projetam levemente” devido ao horizonte no topo da colina. Se o sprite for alto o suficiente, devemos ver sua parte superior, mesmo que o segmento da estrada em que ele está localizado esteja na parte de trás da colina e, portanto, não seja renderizado.

Podemos resolver o último problema salvando o valor de maxycada segmento como uma linha clipna etapa 1. Em seguida, podemos cortar os sprites desse segmento ao longo da linha clipna etapa 2.

O restante da lógica de renderização determina como dimensionar e posicionar o sprite com base no coeficiente scalee nas coordenadas screendos segmentos de estrada (calculado em estágio 1), devido ao qual, no segundo estágio do método render(), temos o seguinte:

// back to front painters algorithm
for(n = (drawDistance-1) ; n > 0 ; n--) {
  segment = segments[(baseSegment.index + n) % segments.length];

  // render roadside sprites
  for(i = 0 ; i < segment.sprites.length ; i++) {
    sprite      = segment.sprites[i];
    spriteScale = segment.p1.screen.scale;
    spriteX     = segment.p1.screen.x + (spriteScale * sprite.offset * roadWidth * width/2);
    spriteY     = segment.p1.screen.y;
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, sprite.source, spriteScale, spriteX, spriteY, (sprite.offset < 0 ? -1 : 0), -1, segment.clip);
  }

  // render other cars
  for(i = 0 ; i < segment.cars.length ; i++) {
    car         = segment.cars[i];
    sprite      = car.sprite;
    spriteScale = Util.interpolate(segment.p1.screen.scale, segment.p2.screen.scale, car.percent);
    spriteX     = Util.interpolate(segment.p1.screen.x,     segment.p2.screen.x,     car.percent) + (spriteScale * car.offset * roadWidth * width/2);
    spriteY     = Util.interpolate(segment.p1.screen.y,     segment.p2.screen.y,     car.percent);
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, car.sprite, spriteScale, spriteX, spriteY, -0.5, -1, segment.clip);
  }

}

Colisões com outdoors e árvores


Agora que podemos adicionar e renderizar sprites de objetos ao longo das margens da estrada, precisamos alterar o método update()para determinar se o jogador encontrou algum desses sprites em seu segmento atual:

usamos um método auxiliar Util.overlap()para implementar o reconhecimento generalizado da interseção de retângulos. Se um cruzamento for detectado, paramos o carro:

if ((playerX < -1) || (playerX > 1)) {
  for(n = 0 ; n < playerSegment.sprites.length ; n++) {
    sprite  = playerSegment.sprites[n];
    spriteW = sprite.source.w * SPRITES.SCALE;
    if (Util.overlap(playerX, playerW, sprite.offset + spriteW/2 * (sprite.offset > 0 ? 1 : -1), spriteW)) {
      // stop the car
      break;
    }
  }
}

Nota: se você estudar o código real, verá que, na verdade, não estamos parando o carro, porque ele não poderá se mover de lado para evitar obstáculos; como um simples hack, fixamos a posição deles e permitimos que o carro "escorregue" para os lados ao redor do sprite.

Colisões com carros


Além de colisões com sprites nas margens da estrada, precisamos reconhecer colisões com outros carros e, se um cruzamento for detectado, desaceleramos o jogador "empurrando" ele para trás do carro com o qual ele colidiu:

for(n = 0 ; n < playerSegment.cars.length ; n++) {
  car  = playerSegment.cars[n];
  carW = car.sprite.w * SPRITES.SCALE;
  if (speed > car.speed) {
    if (Util.overlap(playerX, playerW, car.offset, carW, 0.8)) {
      // slow the car
      break;
    }
  }
}

Atualização da máquina


Para que outros carros se movam pela estrada, daremos a eles a IA mais simples:

  • andar a uma velocidade constante
  • automaticamente percorrer o jogador ao ultrapassar
  • automaticamente circula em outros carros ao ultrapassar

Nota: não precisamos nos preocupar em virar outros carros ao longo de uma curva na estrada, porque as curvas não são reais. Se fizermos os carros se moverem ao longo dos trechos da estrada, eles passarão automaticamente pelas curvas.

Tudo isso acontece durante o ciclo do jogo update()durante uma chamada updateCars()na qual movemos cada carro para frente a uma velocidade constante e mudamos de um segmento para o outro se eles tiverem se distanciado o suficiente durante esse quadro.

function updateCars(dt, playerSegment, playerW) {
  var n, car, oldSegment, newSegment;
  for(n = 0 ; n < cars.length ; n++) {
    car         = cars[n];
    oldSegment  = findSegment(car.z);
    car.offset  = car.offset + updateCarOffset(car, oldSegment, playerSegment, playerW);
    car.z       = Util.increase(car.z, dt * car.speed, trackLength);
    car.percent = Util.percentRemaining(car.z, segmentLength); // useful for interpolation during rendering phase
    newSegment  = findSegment(car.z);
    if (oldSegment != newSegment) {
      index = oldSegment.cars.indexOf(car);
      oldSegment.cars.splice(index, 1);
      newSegment.cars.push(car);
    }
  }
}

O método updateCarOffset()fornece a implementação de "inteligência artificial" , permitindo que a máquina circule pelo player ou por outras máquinas. Este é um dos métodos mais complexos na base de código e, em um jogo real, deve ser muito mais complexo, para que as máquinas pareçam muito mais realistas do que em uma demonstração simples.

Em nosso projeto, usamos uma força bruta de IA ingênua, forçando cada máquina:

  • ansiosos para 20 segmentos
  • se ela encontrar um carro mais lento à sua frente que cruze seu caminho, então contorne
  • vire à direita a partir de obstáculos no lado esquerdo da estrada
  • vire à esquerda dos obstáculos no lado direito da estrada
  • gire o suficiente para evitar obstáculos à frente na distância restante

Também podemos trapacear com os carros que são invisíveis para o jogador, permitindo que eles simplesmente não circulem entre si e passem. Eles devem parecer "inteligentes" apenas dentro da visibilidade do jogador.

function updateCarOffset(car, carSegment, playerSegment, playerW) {

  var i, j, dir, segment, otherCar, otherCarW, lookahead = 20, carW = car.sprite.w * SPRITES.SCALE;

  // optimization, dont bother steering around other cars when 'out of sight' of the player
  if ((carSegment.index - playerSegment.index) > drawDistance)
    return 0;

  for(i = 1 ; i < lookahead ; i++) {
    segment = segments[(carSegment.index+i)%segments.length];

    if ((segment === playerSegment) && (car.speed > speed) && (Util.overlap(playerX, playerW, car.offset, carW, 1.2))) {
      if (playerX > 0.5)
        dir = -1;
      else if (playerX < -0.5)
        dir = 1;
      else
        dir = (car.offset > playerX) ? 1 : -1;
      return dir * 1/i * (car.speed-speed)/maxSpeed; // the closer the cars (smaller i) and the greater the speed ratio, the larger the offset
    }

    for(j = 0 ; j < segment.cars.length ; j++) {
      otherCar  = segment.cars[j];
      otherCarW = otherCar.sprite.w * SPRITES.SCALE;
      if ((car.speed > otherCar.speed) && Util.overlap(car.offset, carW, otherCar.offset, otherCarW, 1.2)) {
        if (otherCar.offset > 0.5)
          dir = -1;
        else if (otherCar.offset < -0.5)
          dir = 1;
        else
          dir = (car.offset > otherCar.offset) ? 1 : -1;
        return dir * 1/i * (car.speed-otherCar.speed)/maxSpeed;
      }
    }
  }
}

Na maioria dos casos, esse algoritmo funciona muito bem, mas com uma grande multidão de carros na frente, podemos notar que os carros estão se movendo da esquerda para a direita e para trás, tentando se espremer no espaço entre as outras duas máquinas. Existem muitas maneiras de melhorar a confiabilidade da IA, por exemplo, você pode permitir que os carros desacelerem se perceberem que não há espaço suficiente para evitar obstáculos.

Interface


Por fim, criaremos uma interface HTML rudimentar:

<div id = "hud">
  <span id = "speed" class = "hud"> <span id = "speed_value" class = "value"> 0 </span> mph </span>
  <span id = "current_lap_time" class = "hud"> Horário: <span id = "current_lap_time_value" class = "value"> 0.0 </span> </span> 
  <span id = "last_lap_time" class = "hud"> Última volta: <span id = "last_lap_time_value" class = "value"> 0.0 </span> </span>
  <span id = "fast_lap_time" class = "hud"> Volta mais rápida: <span id = "fast_lap_time_value" class = "value"> 0.0 </span> </span>
</div>

... e adicione o estilo CSS a ele

#hud                   { position: absolute; z-index: 1; width: 640px; padding: 5px 0; font-family: Verdana, Geneva, sans-serif; font-size: 0.8em; background-color: rgba(255,0,0,0.4); color: black; border-bottom: 2px solid black; box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; }
#hud .hud              { background-color: rgba(255,255,255,0.6); padding: 5px; border: 1px solid black; margin: 0 5px; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud #speed            { float: right; }
#hud #current_lap_time { float: left;  }
#hud #last_lap_time    { float: left; display: none;  }
#hud #fast_lap_time    { display: block; width: 12em;  margin: 0 auto; text-align: center; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud .value            { color: black; font-weight: bold; }
#hud .fastest          { background-color: rgba(255,215,0,0.5); }


... e executaremos sua atualização () durante o ciclo do jogo:

if (position > playerZ) {
  if (currentLapTime && (startPosition < playerZ)) {
    lastLapTime    = currentLapTime;
    currentLapTime = 0;
    if (lastLapTime <= Util.toFloat(Dom.storage.fast_lap_time)) {
      Dom.storage.fast_lap_time = lastLapTime;
      updateHud('fast_lap_time', formatTime(lastLapTime));
      Dom.addClassName('fast_lap_time', 'fastest');
      Dom.addClassName('last_lap_time', 'fastest');
    }
    else {
      Dom.removeClassName('fast_lap_time', 'fastest');
      Dom.removeClassName('last_lap_time', 'fastest');
    }
    updateHud('last_lap_time', formatTime(lastLapTime));
    Dom.show('last_lap_time');
  }
  else {
    currentLapTime += dt;
  }
}

updateHud('speed',            5 * Math.round(speed/500));
updateHud('current_lap_time', formatTime(currentLapTime));

O método auxiliar updateHud()nos permite atualizar elementos DOM apenas quando os valores mudam, porque essa atualização pode ser um processo lento e não devemos fazê-lo a 60 fps se os valores em si não mudarem.

function updateHud(key, value) { // accessing DOM can be slow, so only do it if value has changed
  if (hud[key].value !== value) {
    hud[key].value = value;
    Dom.set(hud[key].dom, value);
  }
}

Conclusão



Fuh! A última parte foi longa, mas ainda terminamos, e a versão final chegou ao estágio em que pode ser chamada de jogo. Ela ainda está longe do jogo terminado , mas ainda é um jogo.

É incrível que nós realmente conseguimos criar um jogo, embora tão simples. Não pretendo levar esse projeto a um estado completo. Deve ser considerado simplesmente como uma introdução ao tópico dos jogos de corrida pseudo-tridimensionais.

O código é publicado pelo github e você pode tentar transformá-lo em um jogo de corrida mais avançado. Você também pode tentar:

  • adicionar efeitos sonoros aos carros
  • melhorar a sincronização de música
  • implementar tela cheia
  • ( , , , ..)
  • (, ..)
  • ,
  • , -
  • ,
  • ( , ..)
  • drawDistance
  • x,y
  • ( , )
  • conexões de garfo e estrada
  • a mudança da noite e do dia
  • condições do tempo
  • túneis, pontes, nuvens, paredes, edifícios
  • cidade, deserto, oceano
  • Adicione Seattle e Space Needle aos fundos
  • "Vilões" - adicione concorrentes para competir
  • modos de jogo - a volta mais rápida, uma em uma corrida (pegando moedas?, atirando em vilões?)
  • toneladas de opções de personalização de jogabilidade
  • etc.
  • ...

Então terminamos. Outro “projeto de final de semana” que demorou muito mais que o esperado, mas no final o resultado foi muito bom.

Referências



Links para demos jogáveis:


All Articles