LimpetGE Logo

LimpetGE Tutoial part 1

The LimpetGE first tutorial. Creation of a working script.

Introduction

LimpetGE is a WebGL game engine library written in Javascript to run in HTML5 (ECMA) compatible browesers, using WebGL. It is designed to facilitate the rapid development of simple WebGL games. It is not a shader writer. Also it has some 3D modelling routines incorporated in it, but simple ones.

This tutorial, with the next, goes through the process of creating a simple game.

Installation and dependncies

At time of writing, the prject is hosted at https://github.com/eddymac/limpetge.

To install dowload the files to a development directory. These should contain the following directory tree:

To "do" this tutorial, it is assumed you will create a direcory "tutorial" (or whatever) and work in there.

Overall structural concept

Before actual coding though, I will go through the overall concepts of LimpetGE.

LimpetGE is a game engine library. It is not a "3D" magic thing, or shader writer. There are however standard shaders, and appropriate interfaces, in the "shaders" directory. (This tutorial will use one of those - "ShaderSimple".)

The library handles creation and placement of game objects, collision detection helpers, sound helpers, WebGL based key input helpers and the such.

Game object structure overview

For game objects, the "structure" hierarchy is:

Once created, Game objects are placed into a "Game object hierarchy":

In the running of the game, game objects are manipulated and tested on as per described.

The Game

The game this tutorial showcases is one called "Squish". It is a very simple game where the player tries to get to the end of a corridor avoiding a whole lot of giant spheres rolling the other way, so as not to be "squished". No sound or anything included here as I did not ant to clutter this first tutorial.

A completed version of this game is included in the examples.

The HTML file

As this is a WebGL game it needs to exist on an HTML page. The one used is:

This is not a HTML tutorial, but to go through respective parts:

Conventions used in LimpetGE

In the LimpetGE library, global variables are used, as are properties on the programmer written "Scene" class. The names of these all start with one of:

The "regex" for this is: _?[lL][A-Z][a-zA_Z_0-9]+

If the programmer avoids variable names that follow the above criteria there will be no clashes, and it is easier to read. Also, is easy to see what is a library supplied variable/property/method easier, especially in this tutorial.!

Now that is out the way - to go to the meat of the game.

The main "squish.js" file

First things first, the top line should read:

"use strict";

If not, you deserve everything you are going to get.

Importing from the Libraries

As performance is an issue, it is best to import all names from the library, and the shaders, individually. At first this looks like a icomplex mess, but it is a simple matter of copy and paste to implement.

import {LAssets, LImage, LAudios, LAudioLoop, LBase, LCamera, LObject, LIObject, LWObject, LStaticGroup, LGroupDef, LStructureDef, LTextureControl, LVirtObject, LGroup, LStructure, LKey, lInput, lInText, LObjImport, LComponent, lInit, lClear, lStructureSetup, lTextureColor, lTextureColorAll, lTextureList, lLoadTexture, lReloadTexture, lLoadTColor, lReloadTColor, lLoadTColors, lReloadTColors, lLoadTCanvas, lReloadTCanvas, lInitShaderProgram, lElement, lAddButton, lCanvasResize, lFromXYZR, lFromXYZ, lFromXYZPYR, lExtendarray, lGetPosition, lAntiClock, lCoalesce, lIndArray, LPRNG, LPRNGD, LCANVAS_ID, LR90, LR180, LR270, LR360, LI_FRONT, LI_BACK, LI_SIDE, LI_TOP, LI_RIGHT, LI_BOTTOM, LI_LEFT, LSTATIC, LDYNAMIC, LNONE, LBUT_WIDTH, LBUT_HEIGHT, LMESTIME, LASSET_THREADS, LASSET_RETRIES, LOBJFILE_SMOOTH, LTMP_MAT4A, LTMP_MAT4B, LTMP_MAT4C, LTMP_QUATA, LTMP_QUATB, LTMP_QUATC, LTMP_VEC3A, LTMP_VEC3B, LTMP_VEC3C, lSScene, LTEXCTL_STATIC, LTEXCTL_STATIC_LIST, lGl, lCamera, lScene, lDoDown, lDoUp, lShader_objects, mat4, vec3, vec4, quat} from "../../libs/limpetge.js"; import {ShaderSimple} from "./shader_squish.js";

The program itselfg...

Looking at the "Structural Concept" tree above, we will start by the structure classes. We will not create the instances at this point, just the classses.

Creating the Corridor structure

Let us do the "corridor" which the player advances through The structure is created within a function that returns an instance of it. This may seem daunting at first, but I will go through it line by line.

Going through the function:

var colors = [ [3.0, 0.2, 0.1, 1.0], // red-brown [0.8, 1.0, 1.0, 1.0], // cyan [0.0, 0.3, 0.0, 1.0], // darkish green [1.0, 1.0, 1.0, 1.0], // White for lines ]

An array named "colors" is created with four colors. The colors are "vec4" type, or an array of four floating numbers, consisting of red, green, blue and alpha values. These values are between 0.0 and 1.0.

var struct = new LStructureDef(ShaderSimple, {colors: colors, collision: LSTATIC});

Create a new instance of the structure definition. Arguments are:

  1. ShaderSimple - A reference to a shader. ShaderSimple is a standard shader, use of which covered later in the tutorial.
  2. A javascript object with a number of optional arguments. These, in this example, are:
var brown = lTextureColor(4, 0); var cyan = lTextureColor(4, 1); var green = lTextureColor(4, 2); var white = lTextureColor(4, 3);

The way LimpetGE deals with colors is it creates a small texture of the colors, consisting of one pixel high and as many colors there are pixels wide, each pixel being the appropriate color. The shader then uses "UV" coordinate matching of that texture to assign the appropriate color to the appropriate surface.

What the "lTextureColor" funcion does is that it converts the arguments given to an appropriate "LTextureControl" instance, which in turn controls the "UV" coordinates in the shader buffer itself. The arguments are:

  1. The number of colors in the array of colors used
  2. The index (starting at zero) of that particular color

What the above code snippet does is to assign each color in the "colors" array defined above to instances that can be used for surfaces in the component creation below.

Now the structure base and other data has been created, we can start adding components.

For the "Squish" game, a unit of "1" in size represents one meter (ish).

First - the corridor. This has two walls (left and right), a floor and ceiling. Each needs to be added.

The walls of the corridor:

struct.addBlock({position: lFromXYZ(-20.1, 0.5, 0), size: [0.1, 2.5, 120], texturecontrols: lIndArray([[LI_RIGHT, brown]])}); struct.addBlock({position: lFromXYZ(20.1, 0.5, 0), size: [0.1, 2.5, 120], texturecontrols: lIndArray([[LI_LEFT, brown]])});

This adds the left wall, then the right. Going through the first line (adding the left wall)..

"addBlock" is a method of a "LStructureDef" class instance that adds a "block". This is like a cube, brick, plank or similar, that has a width, height an depth and right-angled corners, and straight edges and surfaces. Like all components, it takes one argument, a javascript object consisting of a number of named controlling arguments, most of those optional. The ones used, with explanations, are:

Digression - the "lIndArray" function.

What this does is take an array of array pairs, and converts each pair into a [key, value] entry into an object. So:

var xxx = lIndArray([[LI_LEFT, blue], [LI_RIGHT, red]]);

is the same as:

var xxx = {}; xxx[LI_LEFT] = blue; xxx[LI_RIGHT] = red;

Therefore, the first line:

  1. Creates a "block" that goes from 10 centimeters to the left, 2.5 meters below and 120 meters in front, and goes 10 centimeters to the right, 2.5 meters above and 120 meters behind the origin.
  2. Moves that block 20.1 meters to the left
  3. Tells LimpetGE to only renders the right hand side of it in brown.

In effect it tells LimpetGE to render a brown plane 20 meters to the left.

The second line does the same thing, except to the wall on the right.

struct.addBlock({position: lFromXYZ(0, -2.1, 0), size: [20, 0.1, 120], texturecontrols: lIndArray([[LI_TOP, green]]), corners: null}); struct.addBlock({position: lFromXYZ(0, 3.1, 0), size: [20, 0.1, 120], texturecontrols: lIndArray([[LI_BOTTOM, cyan]]), corners: null});

These two lines create a green floor 2 meters below the origin, and a cyan ceiling 3 meters above. I will leave it up to the reader to wok out the mechanics. There is a new optional argument though:

for(var i = -120; i <= 120; i += 10) { struct.addBlock({position: lFromXYZ(0, -2.0, i), size: [20, 0.001, 0.1], texturecontrols: lIndArray([[LI_TOP, white]]), corners: null}); struct.addBlock({position: lFromXYZ(-20.0, 0.5, i), size: [0.001, 2.5, 0.1], texturecontrols: lIndArray([[LI_RIGHT, white]]), corners: null}); struct.addBlock({position: lFromXYZ(20.0, 0.5, i), size: [0.001, 2.5, 0.1], texturecontrols: lIndArray([[LI_LEFT, white]]), corners: null}); }

In order to see advancement along the corridor, the above places white lines 20 centimeters wide every 10 meters along it. It doe this by rendering the top of a white blocks 2 millimeters thick and 20 cetimetrs wide 1 millimetr above the floor, and the same 1 millimeter inside the side walls. Again, not including these in the Collision Detection Mechanism (corners: null).

struct.addBlock({position: lFromXYZ(0, -2.0, -101), size: [20, 0.001, 1], texturecontrols: lIndArray([[LI_TOP, white]]), corners: null}); struct.addBlock({position: lFromXYZ(-20.0, 0.5, -101), size: [0.001, 2.5, 1], texturecontrols: lIndArray([[LI_RIGHT, white]]), corners: null}); struct.addBlock({position: lFromXYZ(20.0, 0.5, -101), size: [0.001, 2.5, 1], texturecontrols: lIndArray([[LI_LEFT, white]]), corners: null});

To include a finish line. A line is created one meter thick at the end using the same method.

That concludes building the components for the corridor. The function then returns the instance when called.

The Corridor structure has been defined. Now for some game objects.

The Scene

For objects to exist they need to have a scene to exist in. We will start with a simple one.

The "Scene" is programmer defined, but it needs to be derived from "LBase". When an instance of this is created, a reference to that instance is automatically copied to the global "lScene" variable. While it is doing that, it also creates a camera object (an "LCamera" instance) and copies that into the global variable "lCamera".

More than one scene or camera can exist at the same time, but cannot be active simultaneously. This is advanced stuff though and beyond this tutorial. For now, we will just have one scene.

To go through the above:

class Scene extends LBase { constructor(args) { super(args); // Set up the keys this.kForward = lInput.press(87); // Key W this.kBack = lInput.press(83); // key S this.kRight = lInput.press(190); // key < or . this.kLeft = lInput.press(188); // key > or , lInput.usekeys(); } }

The constructor. It takes the argument "args", which is a Javascript object with optional named arguments, and passes this to the "LBase" class constructor. Other arguments can be included here of course if the programmer requires it.

The "lInput" object is like a static class, and it handles the keyboard input during the game. The "press(..)" method of this takes a javascript key code of a key as the argument(The "https://keycode.info" web site is useful for determining this), and returns a "LKey" class instance, which has a property "val" which is the boolean "true" when the key is pressed, or "false" when not.

The "lInput.usekeys()" method activates this.

lLoop(delta) { var x = 0; var z = 0; if(this.kForward.val) z -= delta * 5; // How fast we run forward if(this.kBack.val) z += delta * 2.5; // Run backwards half speed if(this.kLeft.val) x -= delta * 2.5; // Same sideways, but slow down forward if(this.kRight.val) x += delta * 2.5; if(x != 0) z *= 0.7; lCamera.moveFlat(x, 0, z); // Need to check if the camera has hit anything var hasHitWall = false; function _seecam(cob) { if(cob.control instanceof Corridor) // Hits a wall hasHitWall = true; } this.lCAllPointDetect(lCamera, 0.3, _seecam); // Has it hit a wall? if(hasHitWall) lCamera.move(-x, 0, 0); // Cannot go too far back if(lCamera.z >= 100) lCamera.move(0, 0, -z); if(lCamera.z <= -100) { return false; } // Continue game return true; },

The "lLoop" function is a virtual function in "LBase" so needs to be included in the "Scene" class. It is called once per frame. It takes the argument "delta" which is a floating point of the number of seconds (not milliseconds) since the last time this was called. I have found this to be a number near 0.013.

Going through the function:

var x = 0; var z = 0; if(this.kForward.val) z -= delta * 5; // How fast we run forward if(this.kBack.val) z += delta * 2.5; // Run backwards half speed if(this.kLeft.val) x -= delta * 2.5; // Same sideways, but slow down forward if(this.kRight.val) x += delta * 2.5; if(x != 0) z *= 0.7; lCamera.moveFlat(x, 0, z);

The above looks at the keyboard entry, and sets the "x" and "z" variables to how much the camera has moved depending on what key is pressed. When going forward, the camera move at 5 meters a second (a good run). When back or side at 2.5 meters a second, and if moving sideways it slows how fast forward or back it is going by a factor of 0.7.

The "moveFlat(....)" method of the lCamera object moves the camera along it's x or z axis. As it does not rotae at all, this is always towards the "back" of the scene.

It is the responsibility of what has moved to see if it has hit anything. So next the collision detection:

var hasHitWall = false; function _seecam(cob) { if(cob.control instanceof Corridor) // Hits a wall hasHitWall = true; } this.lCAllPointDetect(lCamera, 0.3, _seecam); if(hasHitWall) lCamera.move(-x, 0, 0);

A closure function is used here. The main part of the above is the "lCAllPointDetect(....)" method - (defined in "LBase"). This takes three arguments:

  1. lCamera - The Game Object to test detection for (which the camera is one).
  2. Distance - this is the "buffer" distance of the object to test (lCamera in this case). This overides the "distance" property of the object itself if set.
  3. _seecam - The closure function called if a collision occurs. This is called for each collision detected.
  4. .

The closure function itself takes one argument, which is the instance of the object that has been collided with. In the above it looks at the "control" property of that, and if it is "Corridor" (explained next in this tutorial) then it has hit a wall If it has then it needs to move back.

It would be wrong for the "move back" to occur in the closure, as that may be called more than once. LimpetGE employs ray tracing, so if it gets laggy, that scenario may occur.

if(lCamera.z >= 100) lCamera.move(0, 0, -z); if(lCamera.z <= -100) { return false; } return true;

In the above, if the camera tries to move before the start of the corridor it stops it. lCamera.z is the Camera's scene Z coordinates. Then if the camera reaches the end of the corridor, it exits the game ("lLoop" function returns the boolean "false"), otherwise the game continues ("lLoop" function returns boolean "true").

Note: I could have simply tested if the "lCamera.x" property was less than -20 or greater than 20 for wall collision detection, but the reasons here for not are:

The Corridor object

We have created the means for creating a "Corridor" structure. To create the game object based on it:

function Corridor() { this.obj = new LWObject(g_structures.Corridor, this); lScene.lPlace(this.obj, mat4.create()); }

This is a class with no methods. It is created by "new Corridor()". This sets the propery "obj" to a Game object. The constructor of this has:

  1. g_structures.Corridor - The LStructureDef instance for which you are creating an object for. The "g_structures" object I will explain next, it is where to store the structures.
  2. this - The control object assigned to the objects "control" property. Used in collision detection and some shader draw routines. It is usually "this".

The "lScene.lPlace(...)" method places the object in the scene at the top level of the hierarchy. The arguments for this are:

  1. this.obj - The object to place on the scene
  2. mat4.create() - The transformation matrix as to determine where to place the center of the object. In this case, it is the identity matrix, which places that at the center.

To put the whole together...

var g_structures = {}; function g_playgame() { lInit(); // Retrieve and place structure definitions where they can // be accessed later g_structures.Corridor = corridorStructure(); g_playlevel(); } function g_playlevel() { new Scene({lCSize: 5.0, lLDynamic: true, lLDistance: 0.3}); lScene.lDefaultMessage = "W: Forward, S: Back, <: Left, >: Right" lScene.ambientLight = vec3.fromValues(0.3, 0.3, 0.3); lScene.directionalLightColor = vec3.fromValues(1.0, 1.0, 1.0); new Corridor(); lCamera.moveHere(0, 0, 98); lScene.lSetup(); lScene.lMain(); }

The "g_structures" variable is a global one, and in fact, I would normally place it near the top of the jsavascript file.

The "g_playgame()" function is called by the "onload" function of the HTML file. and runs the thing.

The "lInit()" function initialises the WebGL context (lGl variable), and compiles the shaders. The inclusion of shaders is explained below. This should only be called once at the start of everything.

Next to place the instance of LStuctureDef that defines the Corridor into "g_structures.Corridor". This is not necessary per-se, but defining structures can be resource intensive and I have got into the habbit of just doing so once, and storing it in a global variable.

The "g_playlevel()" plays a level. In this initial example this happens only once, so I could have included all "g_playlevel"'s code here. Hoever, anything more complex that would not be right.

In "g_playlevel()":

The "new Scene({....})" constructor. This creates a new "Scene", and places a copy of the instance in the global valriable "lScene", so it does not need to asign it to anything here.

As for the constructor's named arguments suplied here:

The lScene.lDefaultMessage property is the default message displayed in the HTML element Id'd "lTMessage".

The "lScene.ambientLight" is a "vec3" array defining the ambient lighting (the light in the shade) used by the shader (explained below)

The "lScene.directionalLightColor" is a "vec3" array defining the directional light color (sunlight) used by the shader.

The "new Corridor()" statement creates a new corridor as defined above. The constructor places it in the game object hierarchy (using "lScene.lPlace(...)"), so again, needs no assignment here.

The "lCamera.moveHere(....)" method moves the camera to the supplied X, Y and Z co-ordinates. The camera is already pointing "down" the Z axis so it does not need to be rotated.

The "lScene.lSetup()" function needs to be called after all objects are created. It primarily sets up the Static Collision Detection Sparse Array and positions everything in their initial positions.

The "lScene.lMain()" function runs the game.

Finally, in order for "g_playgame" to be seen by the "onload" property of the body, it needs to exist on the "window" object....

/* So that the "onload" attribute of the body element can see this */ window.g_playgame = g_playgame;

The Shader Interface Program - "shader_squish.js"

This uses a standard shader "ShaderSimple". To implement this:

  1. Copy the "shaders/shader_template.js" program to "tutorial/shader_squish.js"
  2. Edit the "tutorial/shader_squish.js" file, and paste the "shaders/simple.js" file into it where it says to do so.
  3. Modify the "lShader_objects" array at the bottom to include "ShaderSimple", so:
    lExtendarray(lShader_objects, [
        ShaderSimple
    ]);
    

That should implement the shader proggram, as long as the script is referenced in a "<script... >" tag in the HTML.

Once done you are ready to run the first iteration of this. The full script to date can be viewed here. It is not a game yet, but you can run something. Here is a link to an HTML page where you can run the script.

Continue to Part two of the tutorial.


Donate