Creating a Pseudo-3D Racing Game: Implementing the Hills and Finishing the Game

Part 3. Hills



In the previous part, we created a simple pseudo-three-dimensional racing game , realizing straight roads and curves in it.

This time we will take care of the hills; fortunately, it will be much easier than creating curved roads.

In the first part, we used the law of similar triangles to create a three-dimensional perspective projection:


... which led us to obtain the equations for projecting the coordinates of the 3d world into the coordinate of the 2d screen.


... but since then we worked only with straight roads, the world coordinates needed only the z component , because both x and y were equal to zero.

This suits us well, because to add hills it’s enough for us to give the road segments the corresponding nonzero coordinate y , after which the existing function will render()magically work.


Yes, that’s enough to get the hills. Just add the y component to the world coordinates of each road segment .

Changes in road geometry


We will modify the existing method addSegmentso that the function calling it can pass p2.world.y , and p1.world.y would correspond to p2.world.y of the previous segment:

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

Add constants to denote low ( LOW), medium ( MEDIUM) and high ( HIGH) hills:

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

Change the existing method addRoad()so that it receives the argument y , which will be used together with the smoothness functions for the gradual ascent and descent from the hill:

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

Further, similar to what we did in part 2 s addSCurves(), we can impose any methods we need to construct the geometry, for example:

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

Changes to the update method


In the arcade game we are creating, we will not try to simulate reality, so the hills do not affect the player or the game world in any way, which means that update()changes are not required in the method .

Hill rendering


render()No changes are required in the method either, because the projection equations were originally written so as to correctly project the road segments with non-zero y coordinates .

Parallax Scrolling Background


In addition to adding y coordinates to all road segments , the only change will be the implementation of the vertical displacement of the background layers along with the hills (just as they move horizontally along with the curves). We implement this with another argument to the helper function Render.background.

The simplest mechanism will be the usual background displacement relative to the position playerY(which should be interpolated from world positions y of the current player segment).

This is not the most realistic behavior, because it is probably worth considering the slope of the current segment of the player’s road, but this effect is simple and works quite well for a simple demo.

Conclusion


That's all, now we can complement the fake curves with real hills:


The work done by us in the first part, including the infrastructure for adding real projected 3d hills, I just did not tell you about this before.

In the last part of the article we will add sprites, as well as trees and billboards along the edges of the road. We will also add other cars against which it will be possible to compete, recognition of collisions and fixing the player’s “circle record”.

Part 4. Ready version



In this part we will add:

  • Billboards and trees
  • Other cars
  • Collision Recognition
  • Rudimentary AI of cars
  • Interface with lap timer and lap record

... and this will provide us with a sufficient level of interactivity to finally call our project a “game."

Note on code structure


, /, Javascript.

. () , ...

… , , , , .



In part 1, before the start of the game cycle, we uploaded a sprite sheet containing all the cars, trees and billboards.

You can manually create a sprite sheet in any image editor, but it is better to entrust the storage of images and the calculation of coordinates to an automated tool. In my case, the sprite sheet was generated by a small Rake task using the Ruby Gem sprite-factory .

This task generates combined spritesheets from separate image files, and also calculates the coordinates x, y, w, h, which will be stored in a constant 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 },
};

Adding billboards and trees


Add to each segment of the road an array that will contain sprites of objects along the edges of the road.

Each sprite consists of sourcetaken from the collection SPRITES, together with a horizontal offset offset, which is normalized so that -1 indicates the left edge of the road, and +1 means the right edge, which allows us not to depend on the value roadWidth.

Some sprites are placed intentionally, others are randomized.

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

  ...
}

Note: if we were creating a real game, we could write a road editor to visually create a map with hills and curves, as well as add a mechanism for arranging sprites along the road ... but for our tasks we can just do it programmatically addSprite().

Adding machines


In addition to sprites of objects at the edges of the road, we will add a collection of cars that will occupy each segment along with a separate collection of all the cars on the highway.

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

Storage of two automobile data structures allows us to easily iteratively go around all the cars in a method update(), moving them from one segment to another if necessary; at the same time, this allows us to execute render()only machines on visible segments.

Each machine is given a random horizontal shift, z position, sprite source and speed:

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

Hill rendering (return)


In the previous parts, I talked about rendering segments of the road, including curves and hills, but there were a few lines of code in them that I did not consider. They concerned a variable maxystarting from the bottom of the screen, but decreasing when rendering each segment to determine which part of the screen we had already rendered:

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

This will allow us to crop segments that will be covered by already rendered hills.

In the artist’s traditional algorithm, rendering usually happens back to front, while closer segments overlap the far ones. However, we cannot spend time rendering polygons, which will eventually be overwritten, so it becomes easier to render from front to back and crop distant segments covered by already rendered near segments if their projected coordinates are smaller maxy.

Rendering billboards, trees and cars


However, iterative traversal of road segments from front to back will not work when rendering sprites, because they often overlap each other, and therefore must be rendered using the artist’s algorithm.

This complicates our method render()and forces us to bypass road segments in two stages:

  1. front to back for road rendering
  2. back forward for rendering sprites


In addition to partially overlapping sprites, we need to deal with sprites that “slightly protrude” due to the horizon at the top of the hill. If the sprite is high enough, then we should see its upper part, even if the segment of the road on which it is located is on the back of the hill, and therefore is not rendered.

We can solve the last problem by saving the value of maxyeach segment as a line clipin step 1. Then we can crop the sprites of this segment along the line clipin step 2.

The rest of the rendering logic determines how to scale and position the sprite based on the coefficient scaleand coordinates screenof the road segments (calculated on stage 1), due to which at the second stage of the method render()we have about the following:

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

}

Collisions with billboards and trees


Now that we can add and render object sprites along the edges of the road, we need to change the method update()to determine whether the player has encountered any of these sprites in his current segment:

We use an auxiliary method Util.overlap()to implement generalized recognition of the intersection of rectangles. If an intersection is detected, we stop the car:

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

Note: if you study the real code, you will see that in fact we are not stopping the car, because then it will not be able to move sideways to avoid obstacles; as a simple hack, we fix their position and allow the car to “slip” to the sides around the sprite.

Collisions with cars


In addition to collisions with sprites along the edges of the road, we need to recognize collisions with other cars, and if an intersection is detected, we slow down the player by “pushing” him back behind the car that he collided with:

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

Machine Update


So that other cars move along the road, we will give them the simplest AI:

  • ride at a constant speed
  • automatically go around the player when overtaking
  • automatically go around other cars when overtaking

Note: we don’t need to worry about turning other cars along a curve on the road, because the curves are not real. If we make the cars just move along the segments of the road, they will automatically pass along the curves.

All this happens during the game cycle update()during a call updateCars()in which we move each car forward at a constant speed and switch from one segment to the next if they have moved a sufficient distance during this frame.

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

The method updateCarOffset()provides the implementation of "artificial intelligence" , allowing the machine to go around the player or other machines. This is one of the most complex methods in the code base, and in a real game it should be much more complex so that the machines seem much more realistic than in a simple demo.

In our project, we use a naive AI brute force, forcing each machine:

  • look forward to 20 segments
  • if she finds a slower car in front of her that crosses her path, then go round her
  • turn right from obstacles on the left side of the road
  • turn left of the obstacles on the right side of the road
  • turn enough to avoid obstacles ahead within the remaining distance

We can also cheat with those cars that are invisible to the player, allowing them to simply not go round each other and pass through. They should seem “smart” only within the player’s visibility.

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

In most cases, this algorithm works quite well, but with a large crowd of cars in front, we can notice that the cars are moving from left to right and back, trying to squeeze into the gap between the other two machines. There are many ways to improve the reliability of AI, for example, you can allow cars to slow down if they see that there is not enough space to avoid obstacles.

Interface


Finally, we will create a rudimentary HTML interface:

<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"> Time: <span id = "current_lap_time_value" class = "value"> 0.0 </span> </span> 
  <span id = "last_lap_time" class = "hud"> Last Lap: <span id = "last_lap_time_value" class = "value"> 0.0 </span> </span>
  <span id = "fast_lap_time" class = "hud"> Fastest Lap: <span id = "fast_lap_time_value" class = "value"> 0.0 </span> </span>
</div>

... and add CSS style to it

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


... and we will execute its update () during the game cycle:

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

The helper method updateHud()allows us to update DOM elements only when the values ​​change, because such an update can be a slow process and we should not do it at 60fps if the values ​​themselves do not change.

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

Conclusion



Fuh! The last part was long, but we still finished, and the finished version reached the stage where it can be called a game. She is still far from the finished game, but it's still a game.

It's amazing that we really managed to create a game, albeit so simple. I do not plan to bring this project to a complete state. It should be considered simply as an introduction to the topic of pseudo-three-dimensional racing games.

The code is posted by github , and you can try to turn it into a more advanced racing game. You can also try:

  • add sound effects to cars
  • improve music sync
  • implement fullscreen
  • ( , , , ..)
  • (, ..)
  • ,
  • , -
  • ,
  • ( , ..)
  • drawDistance
  • x,y
  • ( , )
  • fork and road connections
  • the change of night and day
  • weather conditions
  • tunnels, bridges, clouds, walls, buildings
  • city, desert, ocean
  • Add Seattle and Space Needle to the backgrounds
  • “Villains” - add competitors to compete with
  • game modes - the fastest lap, one on one race (picking up coins ?, shooting at villains?)
  • tons of gameplay customization options
  • etc.
  • ...

So we are done. Another “weekend project” that took a lot longer than expected, but in the end the result was pretty good.

References



Links to playable demos:


All Articles