Creando un juego de carreras pseudo-3D: implementando las colinas y terminando el juego

Parte 3. Colinas



En la parte anterior, creamos un juego de carreras pseudo-tridimensional simple , realizando caminos rectos y curvas en él.

Esta vez cuidaremos de las colinas; Afortunadamente, será mucho más fácil que crear caminos curvos.

En la primera parte, usamos la ley de triángulos similares para crear una proyección de perspectiva tridimensional:


... lo que nos llevó a obtener las ecuaciones para proyectar las coordenadas del mundo 3d en la coordenada de la pantalla 2D.


... pero desde entonces trabajamos solo con carreteras rectas, las coordenadas mundiales solo necesitaban el componente z , porque tanto x como y eran iguales a cero.

Esto se adapta a nosotros también, porque para agregar colinas Es suficiente para nosotros para dar los segmentos de carretera correspondiente distinto de cero de coordenadas y , después de lo cual la función existente render()mágicamente trabajo.


Sí, eso es suficiente para llegar a las colinas. Simplemente agregue el componente y a las coordenadas mundiales de cada segmento de carretera .

Cambios en la geometría de la carretera.


Modificaremos el método existente addSegmentpara que la función que lo llama pueda pasar p2.world.y , y p1.world.y correspondería a p2.world.y del 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;
}

Agregue constantes para denotar colinas bajas ( LOW), medias ( MEDIUM) y 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 }
};

Cambie el método existente addRoad()para que reciba el argumento y , que se usará junto con las funciones de suavidad para el ascenso y descenso gradual de la 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));
}

Además, de manera similar a lo que hicimos en la parte 2 s addSCurves(), podemos imponer cualquier método que necesitemos para construir la geometría, por ejemplo:

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);
}

Cambios en el método de actualización.


En el juego arcade que estamos creando, no intentaremos simular la realidad, por lo que las colinas no afectan al jugador ni al mundo del juego de ninguna manera, lo que significa que update()no se requieren cambios en el método .

Representación de la colina


El método render()tampoco requiere ningún cambio, porque las ecuaciones de proyección se escribieron originalmente para proyectar correctamente los segmentos de carretera con coordenadas y que no son cero .

Fondo de desplazamiento de paralaje


Además de agregar coordenadas y a todos los segmentos de la carretera , el único cambio será la implementación del desplazamiento vertical de las capas de fondo junto con las colinas (tal como se mueven horizontalmente junto con las curvas). Implementamos esto con otro argumento para la función auxiliar Render.background.

El mecanismo más simple será el desplazamiento de fondo habitual en relación con la posición playerY(que debe interpolarse desde las posiciones mundiales y del segmento de jugador actual).

Este no es el comportamiento más realista, porque probablemente valga la pena considerar la pendiente del segmento actual del camino del jugador, pero este efecto es simple y funciona bastante bien para una demostración simple.

Conclusión


Eso es todo, ahora podemos complementar las curvas falsas con colinas reales:


El trabajo realizado por nosotros en la primera parte, incluida la infraestructura para agregar colinas 3D proyectadas reales, simplemente no te lo dije antes.

En la última parte del artículo agregaremos sprites, así como árboles y vallas publicitarias a lo largo de los bordes de la carretera. También agregaremos otros autos contra los cuales será posible competir, el reconocimiento de colisiones y la fijación del "registro de círculo" del jugador.

Parte 4. Versión lista



En esta parte agregaremos:

  • Vallas publicitarias y árboles
  • Otros coches
  • Reconocimiento de colisión
  • IA rudimentaria de automóviles
  • Interfaz con temporizador de vueltas y registro de vueltas

... y esto nos proporcionará un nivel suficiente de interactividad para finalmente llamar a nuestro proyecto un "juego".

Nota sobre la estructura del código


, /, Javascript.

. () , ...

… , , , , .



En la parte 1, antes del inicio del ciclo del juego, subimos una hoja de sprites que contiene todos los autos, árboles y vallas publicitarias.

Puede crear manualmente una hoja de sprites en cualquier editor de imágenes, pero es mejor confiar el almacenamiento de imágenes y el cálculo de coordenadas a una herramienta automatizada. En mi caso, la hoja de sprites fue generada por una pequeña tarea de rastrillo usando la fábrica de sprites Ruby Gem .

Esta tarea genera hojas de sprites combinadas a partir de archivos de imagen separados y también calcula las coordenadas x, y, w, h, que se almacenarán en una 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 },
};

Agregar vallas publicitarias y árboles


Agregue a cada segmento de la carretera una matriz que contendrá sprites de objetos a lo largo de los bordes de la carretera.

Cada sprite consiste en el sourcetomado de la colección SPRITES, junto con un desplazamiento horizontal offset, que se normaliza de modo que -1 indica el borde izquierdo de la carretera, y +1 significa el borde derecho, lo que nos permite no depender del valor roadWidth.

Algunos sprites se colocan intencionalmente, otros se asignan al azar.

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: si estuviéramos creando un juego real, podríamos escribir un editor de carreteras para crear visualmente un mapa con colinas y curvas, así como agregar un mecanismo para organizar sprites a lo largo de la carretera ... pero para nuestras tareas podemos hacerlo programáticamente addSprite().

Máquinas de sumar


Además de sprites de objetos en los bordes de la carretera, agregaremos una colección de autos que ocuparán cada segmento junto con una colección separada de todos los autos en la carretera.

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
    ...
  });
}

El almacenamiento de dos estructuras de datos de automóviles nos permite recorrer fácilmente todos los automóviles en un método update(), moviéndolos de un segmento a otro si es necesario; Al mismo tiempo, esto nos permite ejecutar render()solo máquinas en segmentos visibles.

Cada máquina recibe un desplazamiento horizontal aleatorio, posición z, fuente de sprites y velocidad:

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);
  }
}

Representación de colina (regreso)


En las partes anteriores, hablé sobre la representación de segmentos de la carretera, incluidas curvas y colinas, pero había algunas líneas de código en ellas que no consideré. Se referían a una variable que maxycomenzaba desde la parte inferior de la pantalla, pero que disminuía al renderizar cada segmento para determinar qué parte de la pantalla ya habíamos 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;
}

Esto nos permitirá recortar segmentos que estarán cubiertos por colinas ya renderizadas.

En el algoritmo tradicional del artista, el renderizado generalmente ocurre de atrás hacia adelante, mientras que los segmentos más cercanos se superponen a los lejanos. Sin embargo, no podemos pasar tiempo renderizando polígonos, que eventualmente se sobrescribirán, por lo que se hace más fácil renderizar de adelante hacia atrás y recortar segmentos distantes cubiertos por segmentos ya renderizados cerca si sus coordenadas proyectadas son más pequeñas maxy.

Renderizado de vallas publicitarias, árboles y automóviles


Sin embargo, el recorrido iterativo de los segmentos de carretera de adelante hacia atrás no funcionará al renderizar sprites, ya que a menudo se superponen entre sí y, por lo tanto, deben representarse utilizando el algoritmo del artista.

Esto complica nuestro método render()y nos obliga a evitar los tramos de carretera en dos etapas:

  1. de adelante hacia atrás para renderizar en carretera
  2. atrás adelante para renderizar sprites


Además de los sprites parcialmente superpuestos, tenemos que lidiar con sprites que "sobresalen levemente" debido al horizonte en la cima de la colina. Si el sprite es lo suficientemente alto, entonces deberíamos ver su parte superior, incluso si el segmento del camino en el que se encuentra está en la parte posterior de la colina, y por lo tanto no se representa.

Podemos resolver el último problema guardando el valor de maxycada segmento como una línea clipen el paso 1. Luego podemos recortar los sprites de este segmento a lo largo de la línea clipen el paso 2.

El resto de la lógica de renderizado determina cómo escalar y colocar el sprite en función del coeficiente scaley las coordenadas screende los segmentos de la carretera (calculado en etapa 1), debido a que en la segunda etapa del método render()tenemos sobre lo siguiente:

// 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);
  }

}

Colisiones con vallas publicitarias y árboles.


Ahora que podemos agregar y renderizar sprites de objetos a lo largo de los bordes de la carretera, necesitamos cambiar el método update()para determinar si el jugador ha encontrado uno de estos sprites en su segmento actual:

utilizamos un método auxiliar Util.overlap()para implementar el reconocimiento generalizado de la intersección de rectángulos. Si se detecta una intersección, detenemos el automóvil:

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: si estudia el código real, verá que, de hecho, no detendremos el automóvil, porque no podrá moverse lateralmente para evitar obstáculos; como un simple truco, arreglamos su posición y permitimos que el auto se "deslice" hacia los lados alrededor del sprite.

Colisiones con automóviles


Además de las colisiones con sprites a lo largo de los bordes de la carretera, debemos reconocer las colisiones con otros automóviles, y si se detecta una intersección, reducimos la velocidad del jugador al "empujarlo" detrás de la máquina con la que chocó:

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;
    }
  }
}

Actualización de máquina


Para que otros autos se muevan por el camino, les daremos la IA más simple:

  • conducir a una velocidad constante
  • rodear automáticamente al jugador al adelantar
  • dar la vuelta automáticamente a otros coches al adelantar

Nota: no necesitamos preocuparnos por girar otros autos a lo largo de una curva en la carretera, porque las curvas no son reales. Si hacemos que los autos simplemente se muevan a lo largo de los segmentos de la carretera, pasarán automáticamente a lo largo de las curvas.

Todo esto sucede durante el ciclo del juego update()durante una llamada updateCars()en la que movemos cada automóvil hacia adelante a una velocidad constante y cambiamos de un segmento al siguiente si se han movido una distancia suficiente durante este marco.

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);
    }
  }
}

El método updateCarOffset()proporciona la implementación de "inteligencia artificial" , lo que permite que la máquina rodee al jugador u otras máquinas. Este es uno de los métodos más complejos en la base del código, y en un juego real debería ser mucho más complejo para que las máquinas parezcan mucho más realistas que en una demostración simple.

En nuestro proyecto, usamos una ingenua fuerza bruta de IA, forzando a cada máquina:

  • esperamos 20 segmentos
  • si encuentra un auto más lento frente a ella que se cruza en su camino, entonces dale la vuelta
  • gire a la derecha desde los obstáculos en el lado izquierdo de la carretera
  • gire a la izquierda de los obstáculos en el lado derecho de la carretera
  • gire lo suficiente para evitar obstáculos en la distancia restante

También podemos hacer trampa con esos autos que son invisibles para el jugador, lo que les permite simplemente no rodearse y pasar. Deben parecer "inteligentes" solo dentro de la visibilidad del jugador.

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;
      }
    }
  }
}

En la mayoría de los casos, este algoritmo funciona bastante bien, pero con una gran multitud de autos al frente, podemos notar que los autos se mueven de izquierda a derecha y hacia atrás, tratando de exprimir la brecha entre las otras dos máquinas. Hay muchas formas de mejorar la confiabilidad de la IA, por ejemplo, puede permitir que los autos disminuyan la velocidad si ven que no hay suficiente espacio para evitar obstáculos.

Interfaz


Finalmente, crearemos una interfaz HTML rudimentaria:

<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"> Hora: <span id = "current_lap_time_value" class = "value"> 0.0 </span> </span> 
  <span id = "last_lap_time" class = "hud"> Última vuelta: <span id = "last_lap_time_value" class = "value"> 0.0 </span> </span>
  <span id = "fast_lap_time" class = "hud"> Vuelta más rápida: <span id = "fast_lap_time_value" class = "value"> 0.0 </span> </span>
</div>

... y agregarle estilo CSS

#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); }


... y ejecutaremos su actualización () durante el ciclo del juego:

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));

El método auxiliar updateHud()nos permite actualizar elementos DOM solo cuando cambian los valores, porque dicha actualización puede ser un proceso lento y no deberíamos realizarlo a 60 fps si los valores en sí mismos no cambian.

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);
  }
}

Conclusión



Fuh! La última parte fue larga, pero aún así terminamos, y la versión terminada llegó a la etapa en que se puede llamar un juego. Ella todavía está lejos del juego terminado , pero sigue siendo un juego.

Es sorprendente que realmente hayamos logrado crear un juego, aunque sea tan simple. No planeo llevar este proyecto a un estado completo. Debe considerarse simplemente como una introducción al tema de los juegos de carreras pseudo-tridimensionales.

El código es publicado por github , y puedes intentar convertirlo en un juego de carreras más avanzado. También puedes probar:

  • agregar efectos de sonido a los automóviles
  • mejorar la sincronización musical
  • implementar pantalla completa
  • ( , , , ..)
  • (, ..)
  • ,
  • , -
  • ,
  • ( , ..)
  • drawDistance
  • x,y
  • ( , )
  • tenedor y conexiones por carretera
  • el cambio de noche y día
  • las condiciones climáticas
  • túneles, puentes, nubes, muros, edificios
  • ciudad, desierto, océano
  • Agregue Seattle y Space Needle a los fondos
  • "Villanos" - agregue competidores para competir con
  • modos de juego: la vuelta más rápida, carrera uno a uno (¿recoger monedas ?, ¿disparar a villanos?)
  • toneladas de opciones de personalización de juego
  • etc.
  • ...

Entonces hemos terminado. Otro "proyecto de fin de semana" que tardó mucho más de lo esperado, pero al final el resultado fue bastante bueno.

Referencias



Enlaces a demos jugables:


All Articles