As a child, I rarely went to arcade arcade halls, because I didn’t really need them, because at home I had awesome games for C64 ... but there are three arcade games for which I always had money - Donkey Kong, Dragons Lair and Outrun ...... and I really loved Outrun - speed, hills, palm trees and music, even on the weak version for the C64.So I decided to try writing an old-school pseudo-three-dimensional racing game in the style of Outrun, Pitstop or Pole position. I do not plan to put together a complete and complete game, but it seems to me it will be interesting to re-examine the mechanics with which these games realized their tricks. Curves, hills, sprites and a sense of speed ...So, here is my “weekend project,” which ultimately took five or six weeks on the weekendThe playable version is more like a technical demo than a real game. In fact, if you want to create a real pseudo-three-dimensional race, then this will be the most minimal foundation that you need to gradually turn into a game.It is not polished, a little ugly, but fully functional. I will show you how to implement it on your own in four simple steps.You can also playAbout performance
The performance of this game is very dependent on the machine / browser. In modern browsers, it works well, especially in those that have canvas GPU-acceleration, but a bad graphics driver can cause it to freeze. In the game, you can change the rendering resolution and rendering distance.About the code structure
It so happened that the project was implemented in Javascript (due to the simplicity of prototyping), but it is not intended to demonstrate the techniques or recommended techniques of Javascript. In fact, for ease of understanding, the Javascript of each example is embedded directly in the HTML page (horror!); worse, it uses global variables and functions.If I were creating a real game, the code would be much more structured and streamlined, but since this is a technical demo of a racing game, I decided to stick to KISS .Part 1. Straight roads.
So, how do we get started on creating a pseudo-three-dimensional racing game?Well, we need- Repeat trigonometry
- Recall the basics of 3d projection
- Create a game loop
- Download sprite images
- Build road geometry
- Render background
- Render the road
- Render car
- Implement keyboard support for machine control
But before we begin, let's read Lou's Pseudo 3d Page [ translation Habré] - the only source of information (that I could find) on how to create psevdotrohmernuyu racing game.Finished reading Lou's article? Fine! We will be creating a variation of his Realistic Hills Using 3d-Projected Segments technique. We will do this gradually over the next four parts. But we will start now, with version v1, and create a very simple straight road geometry by projecting it onto an HTML5 canvas element.The demo can be seen here .
A little trigonometry
Before we get into the implementation, let's use the basics of trigonometry to remember how to project a point in the 3D world onto a 2D screen.In the simplest case, if you do not touch vectors and matrices, the law of similar triangles is used for 3D projection .We use the following notation:- h = camera height
- d = distance from camera to screen
- z = distance from camera to car
- y = screen y coordinate
Then we can use the law of similar triangles to calculatey = h * d / z
as shown in the diagram:You could also draw a similar diagram in a top view instead of a side view, and derive a similar equation to calculate the X coordinate of the screen:x = w * d / z
Where w = half the width of the road (from the camera to the edge of the road).As you can see, for x , and y we scale by a factord / z
Coordinate systems
In the form of a diagram, it looks beautiful and simple, but when you start coding, you can get a little confused, because we chose arbitrary names, and it is not clear with what we designated the coordinates of the 3D world, and what the coordinates of the 2D screen are. We also assume that the camera is in the center of the origin of the world, although in reality it will follow the machine.If you approach more formally, then we need to perform:- conversion from world coordinates to screen coordinates
- projecting camera coordinates onto a normalized projection plane
- scaling the projected coordinates to the coordinates of the physical screen (in our case, this is canvas)
Note: in the present 3d-system , the rotation stage is performed between stages 1 and 2 , but since we will simulate the curves, we do not need a rotation.
Projection
The formal projection equations can be represented as follows:- Conversion Equations ( translate ) point is calculated relative to the chamber
- The projection equations ( project ) are variations of the “law of similar triangles” shown above.
- Scaling Equations ( scale ) take into account the difference between:
- math , where 0,0 is in the center and the y axis is up, and
- , 0,0 , y :
: 3d- Vector
Matrix
3d-, , WebGL ( )… . Outrun.
The last piece of the puzzle will be a way to calculate d - the distance from the camera to the projection plane.Instead of just writing a hard-set value of d , it would be more useful to calculate it from the desired vertical field of view. Thanks to this, we will be able to "zoom" the camera if necessary.If we assume that we are projecting onto a normalized projection plane, the coordinates of which are in the range from -1 to +1, then d can be calculated as follows:d = 1 / tan (fov / 2)
By defining fov as one (of many) variables, we can adjust the scope to fine-tune the rendering algorithm.Javascript Code Structure
At the beginning of the article, I already said that the code does not quite comply with the guidelines for writing Javascript - it is a “quick and dirty” demo with simple global variables and functions. However, since I am going to create four separate versions (straight, curves, hills and sprites), I will store some reusable methods inside common.js
within the following modules:- Dom is a few minor DOM helper functions.
- Util - general utilities, mainly auxiliary mathematical functions.
- Game - general gaming support functions, such as image downloader and game loop.
- Render - helper rendering functions on canvas.
I will explain in detail methods from common.js
only if they relate to the game itself, and are not just auxiliary mathematical or DOM functions. Hopefully from the name and context it will be clear what the methods should do.As usual, the source code is in the final documentation.
Simple game loop
Before rendering something, we need a game loop. If you read any of my previous articles about games ( pong , breakout , tetris , snakes or boulderdash ), then you have already seen examples of my favorite game cycle with a fixed time step .I will not go deep into the details, and simply reuse part of the code from previous games to create a game loop with a fixed time step using requestAnimationFrame .The principle is that each of my four examples can call Game.run(...)
and and use its own versionsupdate
- Updating the game world with a fixed time step.render
- Updating the game world when the browser allows.
run: function(options) {
Game.loadImages(options.images, function(images) {
var update = options.update,
render = options.render,
step = options.step,
now = null,
last = Util.timestamp(),
dt = 0,
gdt = 0;
function frame() {
now = Util.timestamp();
dt = Math.min(1, (now - last) / 1000);
gdt = gdt + dt;
while (gdt > step) {
gdt = gdt - step;
update(step);
}
render();
last = now;
requestAnimationFrame(frame);
}
frame();
});
}
Again, this is a remake of ideas from my previous canvas games, so if you don’t understand how the game loop works, then return to one of the previous articles.Images and sprites
Before the game cycle begins, we load two separate spritesheets (sprite sheets):- background - three parallax layers for sky, hills and trees
- sprites - machine sprites (plus trees and billboards to be added to the final version)
The sprite sheet was generated using a small task Rake and Ruby Gem sprite-factory .This task generates the combined sprite sheets, as well as the coordinates x, y, w, h, which will be stored in the constants BACKGROUND
and SPRITES
.Note: I created the backgrounds using Inkscape, and most sprites are graphics taken from the old Outrun version for Genesis and used as training examples.
Game variables
In addition to images of backgrounds and sprites, we will need several game variables, namely:var fps = 60;
var step = 1/fps;
var width = 1024;
var height = 768;
var segments = [];
var canvas = Dom.get('canvas');
var ctx = canvas.getContext('2d');
var background = null;
var sprites = null;
var resolution = null;
var roadWidth = 2000;
var segmentLength = 200;
var rumbleLength = 3;
var trackLength = null;
var lanes = 3;
var fieldOfView = 100;
var cameraHeight = 1000;
var cameraDepth = null;
var drawDistance = 300;
var playerX = 0;
var playerZ = null;
var fogDensity = 5;
var position = 0;
var speed = 0;
var maxSpeed = segmentLength/step;
var accel = maxSpeed/5;
var breaking = -maxSpeed;
var decel = -maxSpeed/5;
var offRoadDecel = -maxSpeed/2;
var offRoadLimit = maxSpeed/4;
Some of them can be customized using UI controls to change critical values during program execution so that you can see how they affect the rendering of the road. Others are recalculated from custom UI values in the method reset()
.We manage Ferrari
We perform key bindings for Game.run
, which provides simple keyboard input that sets or resets variables that report the player’s current actions:Game.run({
...
keys: [
{ keys: [KEY.LEFT, KEY.A], mode: 'down', action: function() { keyLeft = true; } },
{ keys: [KEY.RIGHT, KEY.D], mode: 'down', action: function() { keyRight = true; } },
{ keys: [KEY.UP, KEY.W], mode: 'down', action: function() { keyFaster = true; } },
{ keys: [KEY.DOWN, KEY.S], mode: 'down', action: function() { keySlower = true; } },
{ keys: [KEY.LEFT, KEY.A], mode: 'up', action: function() { keyLeft = false; } },
{ keys: [KEY.RIGHT, KEY.D], mode: 'up', action: function() { keyRight = false; } },
{ keys: [KEY.UP, KEY.W], mode: 'up', action: function() { keyFaster = false; } },
{ keys: [KEY.DOWN, KEY.S], mode: 'up', action: function() { keySlower = false; } }
],
...
}
The player’s state is controlled by the following variables:- speed - current speed.
- position - the current Z position on the track. Note that this is a camera position, not a Ferrari.
- playerX - player’s current position on X on the road. Normalized in the range from -1 to +1, so as not to depend on the actual value
roadWidth
.
These variables are set inside the method update
, which performs the following actions:- updates
position
based on the current speed
. - updates
playerX
when you press the left or right key. - increases
speed
if the up key is pressed. - decreases
speed
if the down key is pressed. - reduces
speed
if the up and down keys are not pressed. - reduces
speed
if playerX
located off the edge of the road and on the grass.
In the case of direct roads, the method is update
quite clear and simple:function update(dt) {
position = Util.increase(position, dt * speed, trackLength);
var dx = dt * 2 * (speed/maxSpeed);
if (keyLeft)
playerX = playerX - dx;
else if (keyRight)
playerX = playerX + dx;
if (keyFaster)
speed = Util.accelerate(speed, accel, dt);
else if (keySlower)
speed = Util.accelerate(speed, breaking, dt);
else
speed = Util.accelerate(speed, decel, dt);
if (((playerX < -1) || (playerX > 1)) && (speed > offRoadLimit))
speed = Util.accelerate(speed, offRoadDecel, dt);
playerX = Util.limit(playerX, -2, 2);
speed = Util.limit(speed, 0, maxSpeed);
}
Do not worry, it will become much more difficult when in the finished version we add sprites and collision recognition.Road geometry
Before we can render the game world, we need to build an array of segments
in in the method resetRoad()
.Each of these segments of the road will ultimately be projected from its world coordinates so that it turns into a 2d polygon in screen coordinates. For each segment, we store two points, p1 is the center of the edge closest to the camera, and p2 is the center of the edge farthest from the camera.Strictly speaking, p2 of each segment is identical to p1 of the previous segment, but it seems to me that it is easier to store them as separate points and convert each segment separately.We keep separate rumbleLength
because we can have beautiful detailed curves and hills, but at the same time horizontal stripes. If each subsequent segment has a different color, then this will create a bad strobe effect. Therefore, we want to have many small segments, but group them together to form separate horizontal stripes.function resetRoad() {
segments = [];
for(var n = 0 ; n < 500 ; n++) {
segments.push({
index: n,
p1: { world: { z: n *segmentLength }, camera: {}, screen: {} },
p2: { world: { z: (n+1)*segmentLength }, camera: {}, screen: {} },
color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
});
}
trackLength = segments.length * segmentLength;
}
We initialize p1 and p2 only with world coordinates z , because we only need straight roads. The y coordinates will always be 0, and the x coordinates will always depend on the scaled value +/- roadWidth
. Later, when we add curves and hills, this part will change.We will also set empty objects to store representations of these points in the camera and on the screen so as not to create a bunch of temporary objects in each render
. To minimize garbage collection, we must avoid allocating objects within the game loop.When the car reaches the end of the road, we simply return to the beginning of the cycle. To simplify this, we will create a method to find a segment for any value of Z, even if it goes beyond the length of the road:function findSegment(z) {
return segments[Math.floor(z/segmentLength) % segments.length];
}
Background rendering
The method render()
begins by rendering the background image. In the following parts, where we will add curves and hills, we will need the background to perform parallax scrolling, so we will now begin to move in this direction, rendering the background as three separate layers:function render() {
ctx.clearRect(0, 0, width, height);
Render.background(ctx, background, width, height, BACKGROUND.SKY);
Render.background(ctx, background, width, height, BACKGROUND.HILLS);
Render.background(ctx, background, width, height, BACKGROUND.TREES);
...
Road rendering
Then, the render function iterates through all the segments and projects p1 and p2 of each segment from world coordinates to screen coordinates, trimming the segment if necessary, and otherwise rendering it: var baseSegment = findSegment(position);
var maxy = height;
var n, segment;
for(n = 0 ; n < drawDistance ; n++) {
segment = segments[(baseSegment.index + n) % segments.length];
Util.project(segment.p1, (playerX * roadWidth), cameraHeight, position, cameraDepth, width, height, roadWidth);
Util.project(segment.p2, (playerX * roadWidth), cameraHeight, position, cameraDepth, width, height, roadWidth);
if ((segment.p1.camera.z <= cameraDepth) ||
(segment.p2.screen.y >= maxy))
continue;
Render.segment(ctx, width, lanes,
segment.p1.screen.x,
segment.p1.screen.y,
segment.p1.screen.w,
segment.p2.screen.x,
segment.p2.screen.y,
segment.p2.screen.w,
segment.color);
maxy = segment.p2.screen.y;
}
Above, we have already seen the calculations necessary for projecting a point; The javascript version combines transformation, projection and scaling into one method:project: function(p, cameraX, cameraY, cameraZ, cameraDepth, width, height, roadWidth) {
p.camera.x = (p.world.x || 0) - cameraX;
p.camera.y = (p.world.y || 0) - cameraY;
p.camera.z = (p.world.z || 0) - cameraZ;
p.screen.scale = cameraDepth/p.camera.z;
p.screen.x = Math.round((width/2) + (p.screen.scale * p.camera.x * width/2));
p.screen.y = Math.round((height/2) - (p.screen.scale * p.camera.y * height/2));
p.screen.w = Math.round( (p.screen.scale * roadWidth * width/2));
}
In addition to calculating the screen x and y for each point p1 and p2, we use the same projection calculations to calculate the projected width ( w ) of the segment.Having the screen coordinates x and y of points p1 and p2 , as well as the projected width of the road w , we can quite easily calculate with the help of an auxiliary function Render.segment
all the polygons necessary for rendering grass, road, horizontal stripes and dividing lines, using the general auxiliary function Render.polygon
(see . common.js
) .Car rendering
Finally, the last thing the method needs render
is a Ferrari rendering: Render.player(ctx, width, height, resolution, roadWidth, sprites, speed/maxSpeed,
cameraDepth/playerZ,
width/2,
height);
This method is called player
, and not car
, because in the final version of the game there will be other cars on the road, and we want to separate the player's Ferrari from other cars.The helper function Render.player
uses the canvas method called drawImage
to render the sprite, having previously scaled it using the same projection scaling that was used before:d / z
Where z in this case is the relative distance from the machine to the camera, stored in the variable playerZ .In addition, the function “shakes” the car a little at high speeds, adding a bit of randomness to the scaling equation, depending on speed / maxSpeed .And here is what we got:Conclusion
We did a fairly large amount of work just to create a system with straight roads. We added- generic helper module dom
- Util general math module
- Render general canvas helper module ...
- ... including
Render.segment
, Render.polygon
andRender.sprite
- fixed pitch game cycle
- image downloader
- keyboard handler
- parallax background
- sprite sheet with cars, trees and billboards
- rudimentary geometry of the road
- method
update()
for controlling the machine - method
render()
for rendering background, road and player’s car - HTML5 tag
<audio>
with racing music (hidden bonus!)
... which gave us a good foundation for further development.Part 2. Curves.
In this part, we will explain in more detail how curves work.In the previous part, we compiled the geometry of the road in the form of an array of segments, each of which has world coordinates that are transformed relative to the camera and then projected onto the screen.We needed only the world coordinate z for each point, because on straight roads both x and y were equal to zero.If we were to create a fully functional 3d system, we could implement the curves by calculating the x and z stripes of the polygons shown above. However, this type of geometry will be rather difficult to calculate, and for this it will be necessary to add the 3d-rotation stage to the projection equations ...... if we went this way, it would be better to use WebGL or its analogues, but this project does not have other tasks for our project. We just want to use old-school pseudo-three-dimensional tricks to simulate curves.Therefore, you will probably be surprised to learn that we will not calculate the x coordinates of the road segments at all ...Instead, we will use Lu’s advice :“To curve the road, simply change the position of the center line of the curve shape ... starting from the bottom of the screen, the amount of shift of the center of the road left or right gradually increases . ”
In our case, the center line is the value cameraX
passed to the projection calculations. This means that when we perform render()
each segment of the road, you can simulate the curves by shifting the value cameraX
by a gradually increasing value.To know how much to shift, we need to store a value in each segment curve
. This value indicates how much the segment should be shifted from the center line of the camera. She will be:- negative for left-turning curves
- positive for curves turning right
- less for smooth curves
- more for sharp curves
The values themselves are chosen quite arbitrarily; through trial and error, we can find good values at which the curves seem to be “correct”:var ROAD = {
LENGTH: { NONE: 0, SHORT: 25, MEDIUM: 50, LONG: 100 },
CURVE: { NONE: 0, EASY: 2, MEDIUM: 4, HARD: 6 }
};
In addition to choosing good values for the curves, we need to avoid any gaps in the transitions when the line turns into a curve (or vice versa). This can be achieved by softening when entering and exiting the curves. We will do this by gradually increasing (or decreasing) the value curve
for each segment using traditional smoothing functions until it reaches the desired value:easeIn: function(a,b,percent) { return a + (b-a)*Math.pow(percent,2); },
easeOut: function(a,b,percent) { return a + (b-a)*(1-Math.pow(1-percent,2)); },
easeInOut: function(a,b,percent) { return a + (b-a)*((-Math.cos(percent*Math.PI)/2) + 0.5); },
That is, now, taking into account the function of adding one segment to the geometry ...function addSegment(curve) {
var n = segments.length;
segments.push({
index: n,
p1: { world: { z: n *segmentLength }, camera: {}, screen: {} },
p2: { world: { z: (n+1)*segmentLength }, camera: {}, screen: {} },
curve: curve,
color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
});
}
we can create a method for smooth entry, finding and smooth exit from a curved road:function addRoad(enter, hold, leave, curve) {
var n;
for(n = 0 ; n < enter ; n++)
addSegment(Util.easeIn(0, curve, n/enter));
for(n = 0 ; n < hold ; n++)
addSegment(curve);
for(n = 0 ; n < leave ; n++)
addSegment(Util.easeInOut(curve, 0, n/leave));
}
... and on top you can impose additional geometry, for example, S-shaped curves:function addSCurves() {
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, -ROAD.CURVE.EASY);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.CURVE.MEDIUM);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.CURVE.EASY);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, -ROAD.CURVE.EASY);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, -ROAD.CURVE.MEDIUM);
}
Changes to the update () Method
The only changes that need to be made to the method update()
are the application of a kind of centrifugal force when the machine moves along a curve.We set an arbitrary factor that can be adjusted according to our preferences.var centrifugal = 0.3;
And then we will just update the position playerX
based on its current speed, curve value and centrifugal force multiplier:playerX = playerX - (dx * speedPercent * playerSegment.curve * centrifugal);
Curve rendering
We said above that you can render simulated curves by shifting the value cameraX
used in projection calculations during the execution of render()
each road segment.To do this, we will store the drive variable dx , increasing for each segment by a value curve
, as well as the variable x , which will be used as the offset of the value cameraX
used in projection calculations.To implement the curves, we need the following:- shift the projection p1 of each segment by x
- shift the projection p2 of each segment by x + dx
- increase x for the next segment by dx
Finally, in order to avoid torn transitions when crossing the boundaries of segments, we must make dx initialized with the interpolated value of the curve of the current base segments.Change the method render()
as follows:var baseSegment = findSegment(position);
var basePercent = Util.percentRemaining(position, segmentLength);
var dx = - (baseSegment.curve * basePercent);
var x = 0;
for(n = 0 ; n < drawDistance ; n++) {
...
Util.project(segment.p1, (playerX * roadWidth) - x, cameraHeight, position - (segment.looped ? trackLength : 0), cameraDepth, width, height, roadWidth);
Util.project(segment.p2, (playerX * roadWidth) - x - dx, cameraHeight, position - (segment.looped ? trackLength : 0), cameraDepth, width, height, roadWidth);
x = x + dx;
dx = dx + segment.curve;
...
}
Parallax Scrolling Background
Finally, we need to scroll the parallax background layers, storing the offset for each layer ...var skySpeed = 0.001;
var hillSpeed = 0.002;
var treeSpeed = 0.003;
var skyOffset = 0;
var hillOffset = 0;
var treeOffset = 0;
... and increasing it during time update()
depending on the curve value of the current player segment and its speed ...skyOffset = Util.increase(skyOffset, skySpeed * playerSegment.curve * speedPercent, 1);
hillOffset = Util.increase(hillOffset, hillSpeed * playerSegment.curve * speedPercent, 1);
treeOffset = Util.increase(treeOffset, treeSpeed * playerSegment.curve * speedPercent, 1);
... and then use to use this offset when doing render()
background layers.Render.background(ctx, background, width, height, BACKGROUND.SKY, skyOffset);
Render.background(ctx, background, width, height, BACKGROUND.HILLS, hillOffset);
Render.background(ctx, background, width, height, BACKGROUND.TREES, treeOffset);
Conclusion
So, here we get the fake pseudo-three-dimensional curves:The main part of the code that we added is to build the geometry of the road with the corresponding value curve
. Realizing it, adding centrifugal force during the time is update()
much easier.Curve rendering is performed in just a few lines of code, but it can be difficult to understand (and describe) what exactly is happening here. There are many ways to simulate curves and it is very easy to wander when they are implemented into a dead end. It is even easier to get carried away with an outside task and try to do everything “correctly”; before you realize this, you will begin to create a fully functional 3d-system with matrices, rotations and real 3d-geometry ... which, as I said, is not our task.When I wrote this article, I was sure that there definitely were problems in my implementation of the curves. Trying to visualize the algorithm, I did not understand why I needed two values of the dx and x drives instead of one ... and if I can’t fully explain something, then something went wrong somewhere ...... but the project’s time “on the weekend ”has almost expired, and, frankly, the curves seem to me quite beautiful, and in the end, this is the most important.