Tutorial, Part 4: Graphics


< Previous Tutorial (Part 3)

> Next Tutorial (Part 5)


We have the important foundation for starting the game with complete, such as the player position, snack position and map tile data. We can get to work on making that appear on the screen.

For this tutorial, we will be working exclusively with the scripts.js page.

One of the most resource-consuming aspects of any javascript project is outputting anything on the screen; text, doing stuff on a canvas, etc. Typically, a javascript game would draw a whole bunch of tiles at a time, advance a frame, then draw them again. This game will be taking a very different approach; we will build the page by using and modifying an array of RGBA values, then pasting it.

For this game, the process of drawing graphics onto the screen will involve:

  • Creating a blank white box on the canvas.
  • Converting that into an array of red, green, blue and alpha values. The size of it would be the width x height x 4 values per pixel, giving us 65,536 values (for this game).
  • Modifying these as needed, changing red, green and blue values.
  • Returning this to the canvas screen with the edited results.

It sounds complicated, but it has the potential to be faster. The biggest advantage with this style is we are limiting the number of times we are drawing on the screen per frame. For more complicated projects, (Like a 3D raycaster, for instance), this would produce a far better frame rate than trying to rely on drawing a pixel at a time, which would quickly (and easily) bog down the whole game.

Graphics Prep

We have several functions we will require.

To start with, let's setup the functions that pull and return the graphics data. When we want to manipulate what appears on the screen, we will call on the STARTFGX() to get the graphics data going and when it is done, we will call ENDGFX() to post it on the screen.

Under where the SNACKSPOT() is, add the following:

//
// -- Graphics.
function STARTGFX(){
    gfx=GAMEX.getImageData(0,0,scrWidth,scrHeight); //Create an array from the existing screen data.
}
function ENDGFX(){
    GAMEX.putImageData(gfx,0,0); //Draw a page based on the created and modified array data.
    gfx=null;                    //Clears the array.
}

For us to draw blocks (or anything for that matter), we will need something that includes the position, size and colour we are going to draw. We will use a function that takes the data given to it (like and x and y position on the screen, how big the block is, colour, etc.), gets precise points within the array and adjusts their colour values.

Place this block of code after the ENDGFX() function.

//
// -- Draw some pixels on the screen.
function DRAWBLOCK(x,y,w,h,r,g,b){ //X & Y starting point, width, height, red, green, blue
    let a;
    for(let i=0; i<h; i++){                                          //Loop the width and height to draw a box.
        for(let j=0; j<w; j++){
            if(x+j>=0 && x+j<scrWidth && y+i>=0 && y+i<scrHeight){//Make sure the pixel in question is not out of bounds.
                a=((y*scrWidth)+(i*scrWidth)+x+j)*4;                    //Get the pixel position within the graphics array data.
                gfx.data[a+0]=r;                                        //Update the red, green and blue values.
                gfx.data[a+1]=g;
                gfx.data[a+2]=b;
            }
        }
    }
}

We also have text we will want to draw. Early on we included some data in the fonts.js. What that is is a list of X & Y values, width and heights. When we want to draw letter, we will call on the DRAWBLOCK() a few times to draw dots and lines to create letters on the screen. The TEXT() function will do that with the list we placed in fonts.js.

Place this underneath the DRAWBLOCK() function:

//
// -- Draw some text on the screen.
function TEXT(x,y,a){
    let i0, j0, txtSetup;            //Function-specific variables for looping and the text characters.
    a=a.toString();                  //Make sure that the input is not a number.
    i0=a.length;
    for(let i=0; i<i0; i++){
        txtSetup=fontSetup[a[i]];    //ID the letter
        let j0=txtSetup.length;      //Determine how many times the draw function is being called
        for (let j=0; j<j0; j+=4){//Shadow
            DRAWBLOCK(x+(i*6)+txtSetup[j],y+2+txtSetup[j+1],txtSetup[j+2],txtSetup[j+3],224,224,224);
        }
        for (let j=0; j<j0; j+=4){//Main colour
            DRAWBLOCK(x+(i*6)+1+txtSetup[j],y+1+txtSetup[j+1],txtSetup[j+2],txtSetup[j+3],48,48,48);
        }
    }
}

Player's Head

With all the tools in place, let's try to draw something. We will start with drawing the head of the player's worm.

First, go to the SETSIZE() function and remove the line that sets the background.

GAME.style.background="white";

This line will no longer be necessary, since we will be drawing a white block in the ACTION() function.

    function SETSIZE(){ //Set the size of the screen.
    let i,i2;                                           //Set 'css' size (of the canvas).
    i=(innerHeight-4)/scrWidth,i2=innerWidth/scrHeight; //This will use get the dimensions of the screen as a multiplier of the game screen base size.
    if(i2<i){i=i2}                                      //Whichever is the smaller scaleup value will be used.
    if(i<1){i=1;}                                       //This sets a minimum size.
    GAME.style.width=Math.floor(scrWidth*i)+"px";       //Set the canvas 'css' size.
    GAME.style.height=Math.floor(scrHeight*i)+"px";
}

Next, go down to ACTION(). We will be adding some lines to fire up the function's ability to draw some stuff on the screen. We will draw a white box, turn on the graphics capture which will grab the white box we drew and make it into a lengthy array of data, use the DRAWBLOCK() function to draw an orange block on the screen, then output the change to the screen.

function ACTION(){
    //Keyboard controls
    if(KEYBOARD(0)){}      //Left
    else if(KEYBOARD(1)){} //Up
    else if(KEYBOARD(2)){} //Right
    else if(KEYBOARD(3)){} //Down
    if(KEYBOARD(4)){}      //Pause Game?
    //Graphics
    GAMEX.fillStyle=`rgb(255,255,255)`; //Beginning drawing, starting with a white background
    GAMEX.fillRect(0,0,scrWidth,scrHeight);
    STARTGFX();
    DRAWBLOCK(pBody[0][0]*8,pBody[0][1]*8+16,7,7,224,128,48);
    ENDGFX();                           //Draw the graphics.
    //
    requestAnimationFrame(ACTION);
}

If this was done correctly, it should draw a little orange block on the screen. It will be a little bit off-centre, but it should appear.

Other Graphics

We have established the drawing concept works. Let's add some more content.

First we will sneak in a line of variables that will be used by the various lines in the graphics section. Introduce this to the top of the ACTION() function:

    let x, y, bL;  //Useful in-function variables: X&Y positions | body Length

Next, we will make a modification to the worm being drawn. Since we plan to draw all the body segments, we will setup a simple loop for the length of the body and have it draw a block based on where this is on the loop:

  • First one draws the orange head.
  • Last one draws a smaller green tail piece.
  • Anything else draws a large green body.
  • Because we do not want to draw the head along the very top of the screen, we are including a small cutoff in the part that draws the head to make sure it does not accidentally draw one beyond the confines of the walls.
  • Update the following into the //Graphics sub-section in the ACTION() function. This will add the loop that draws the entire worm and remove the original line that drew just the orange head on the screen.
    //Graphics
    GAMEX.fillStyle=`rgb(255,255,255)`; //Beginning drawing, starting with a white background
    GAMEX.fillRect(0,0,scrWidth,scrHeight);
    STARTGFX();
    bL=pBody.length;
    for(let y=bL-1; y>=0; y--){         //Worm. Check is included to see if the head, body or tail is being
                                        //drawn.  We do this backwards, so the head is drawn last.
    if(pBody[y][1]>=0){
            if(y==0 && endRound==0){DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,224,128,48);}  //Head. Do not draw this if the head is outside the room.
            else if (y==bL-1){DRAWBLOCK(pBody[y][0]*8+1,pBody[y][1]*8+17,5,5,80,192,64);}       //Tail
            else {DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,80,192,64);}                     //Body
        }
    }
    ENDGFX();

Next, we can draw all the map tile blocks. We will use a simple for loop for the Y-position and another one for the X-position, check if the tile is a wall and draw a dark grey block if so.

  • The map array is setup as a single line, rather than arrays within an array, so everytime we want to go down 1 along the Y-axis, we have to increase the search by the width of the map. So, if we want to check the tile at (1,4), we will look up mapX[4 * 16 + 1], which is the same as mapX[65].
  • Update the following into the //Graphics sub-section in the ACTION() function. This will add the loop that draws the map tiles on the screen.
    //Graphics
    GAMEX.fillStyle=`rgb(255,255,255)`; //Beginning drawing, starting with a white background
    GAMEX.fillRect(0,0,scrWidth,scrHeight);
    STARTGFX();
    for(let y=0; y<14; y++){            //Draw the map tiles; 16x14 grid.
        for(let x=0; x<16; x++){
            if(mapX[(y*16)+x]==1){DRAWBLOCK(x*8,(y*8)+16,8,8,48,48,48);}
        }
    }
    bL=pBody.length;
    for(let y=bL-1; y>=0; y--){         //Worm. Check is included to see if the head, body or tail is being
                                        //drawn.  We do this backwards, so the head is drawn last.
    if(pBody[y][1]>=0){
            if(y==0 && endRound==0){DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,224,128,48);}  //Head. Do not draw this if the head is outside the room.
            else if (y==bL-1){DRAWBLOCK(pBody[y][0]*8+1,pBody[y][1]*8+17,5,5,80,192,64);}       //Tail
            else {DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,80,192,64);}                     //Body
        }
    }
    ENDGFX();

And this is how the game should look, so far.

The block does not completely cover the entire screen, since there are plans for the blank area along the top of the canvas. We will include some text that gives the player data on the progress of the game.

    Lives counter: How many the player has left.
  • Current round.
  • Score. In addition, a small adjustment is made include zeros where the digits count is less than five.
  • # of remaining snacks.
  • Including the following underneath the player body.
    //Graphics
    GAMEX.fillStyle=`rgb(255,255,255)`; //Beginning drawing, starting with a white background
    GAMEX.fillRect(0,0,scrWidth,scrHeight);
    STARTGFX();
    for(let y=0; y<14; y++){            //Draw the map tiles; 16x14 grid.
        for(let x=0; x<16; x++){
            if(mapX[(y*16)+x]==1){DRAWBLOCK(x*8,(y*8)+16,8,8,48,48,48);}
        }
    }
    bL=pBody.length;
    for(let y=bL-1; y>=0; y--){         //Worm. Check is included to see if the head, body or tail is being
                                        //drawn.  We do this backwards, so the head is drawn last.
    if(pBody[y][1]>=0){
            if(y==0 && endRound==0){DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,224,128,48);}  //Head. Do not draw this if the head is outside the room.
            else if (y==bL-1){DRAWBLOCK(pBody[y][0]*8+1,pBody[y][1]*8+17,5,5,80,192,64);}       //Tail
            else {DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,80,192,64);}                     //Body
        }
    }
    TEXT(0,0,"LIVES:"+pLives);                          //Top line details
    TEXT(0,8,"ROUND:"+(pRound+1));
    let p=pScore.toString(); while(p.length<5){p="0"+p;}
    TEXT(60,0,"SCORE:"+p);
    TEXT(60,8,"SNACKS:"+pSnack);
    ENDGFX();                                           //Draw the graphics.

And the screen should now look like this.

This is starting to look fairly complete, but we are still missing an important element: Where is the snack? In the STARTGAME() function, we had setup an X and Y place for a snack, plus another value which will give us a colour based on it's bonus point value (whatever is above and beyond the base points given by it). A small check is also included to make sure the snack is within the confines of the game screen (It gets moved well outside the bounds of the game when the pSnack variable (snack counter) is at 0.

Place the following before the head and after the walls:

    //Graphics
    GAMEX.fillStyle=`rgb(255,255,255)`; //Beginning drawing, starting with a white background
    GAMEX.fillRect(0,0,scrWidth,scrHeight);
    STARTGFX();
    for(let y=0; y<14; y++){            //Draw the map tiles; 16x14 grid.
        for(let x=0; x<16; x++){
            if(mapX[(y*16)+x]==1){DRAWBLOCK(x*8,(y*8)+16,8,8,48,48,48);}
        }
    }
    if(snack[0]>=0){                    //Should the snack be drawn?
        DRAWBLOCK(snack[0]*8+1,snack[1]*8+17,5,5,snackColors[snack[2]][0],snackColors[snack[2]][1],snackColors[snack[2]][2]);
    }
    bL=pBody.length;
    for(let y=bL-1; y>=0; y--){         //Worm. Check is included to see if the head, body or tail is being
                                        //drawn.  We do this backwards, so the head is drawn last.
    if(pBody[y][1]>=0){
            if(y==0 && endRound==0){DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,224,128,48);}  //Head. Do not draw this if the head is outside the room.
            else if (y==bL-1){DRAWBLOCK(pBody[y][0]*8+1,pBody[y][1]*8+17,5,5,80,192,64);}       //Tail
            else {DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,80,192,64);}                     //Body
        }
    }
    TEXT(0,0,"LIVES:"+pLives);                          //Top line details
    TEXT(0,8,"ROUND:"+(pRound+1));
    let p=pScore.toString(); while(p.length<5){p="0"+p;}
    TEXT(60,0,"SCORE:"+p);
    TEXT(60,8,"SNACKS:"+pSnack);
    ENDGFX();                                           //Draw the graphics.

Now we should have a snack appearing somewhere on the screen in one of five possible colours.

Here is the up-to-date progress for the code:

//FIRST THINGS, FIRST
"use strict";  //Help better identify problems in the code, especially with bad variable declarations.
//
//VARIABLES
let keyPress=[false,false,false,false,false],keyHold=[0,0,0,0,0]; //controls for keys, notably if one if being pressed/held down
//
let pBody,     //Player's body. This will be an array to store the X and Y positions of each body segment.
    pCollect,  //How many snacks the player had nomnom'ed during this round.
    pDeaths,   //Player deaths. We will add a score bonus if the player completes a level without dying once.
    pDir,      //Player's direction. This will match the keys layout (0=left, 1=up, 2=right, 3=down.
               //  4 is used as a placeholder to say there is no direction and the player will not automatically move)
    pLifeScore,//How many points a player must earn to gain another life.
    pLifeBase, //Base for score required for another life.
    pLifeInc,  //The bonus life score increment.
    pLives,    //Player's remaining lives. If this is at zero when the player crashes, it's game over.
    pSegMax,   //How many body segments the player should have.
    pRound,    //Player's current round.
    pScore,    //Player's score
    pSnack,    //How many snacks the player must collect to clear a round.
    pTimer;    //Player timer. This is used to track the # of remaining frames
let endRound,  //When the player exits the screen, this ends the round.
    gfx,       //The value that is used for building the graphics output.
    gTimer,    //Game timer. Useful for different things.
    mapX,      //Current map.
    snack,     //Snack. This holds the the X, Y and color values.
    tempDir;   //Temporary direction. This value is moved to pDir when the player moves a square.
//
//CONSTANTS
const addLength=[0,8,7,7,6,6,6,5,5,5,5],   //A setup for how many body segments are added on when eating a snack.
                                           //Connected to the pCollect variable.
      dirX=[-1,0,1,0],dirY=[0,-1,0,1],     //Controls the changes to the player's X and Y when moving.
      keys=[37,38,39,40,32],               //Keycode values while pressing keys. For WASD, use: keys=[37,38,39,40,32];
      GAME=document.getElementById("game"),//Shortcut for accessing the game canvas.
      GAMEX=GAME.getContext("2d"),         //Allows us to handle the game canvas more easily when making graphics.
      scrWidth=128, scrHeight=128,         //Screen width and height.
      snackColors=[[32,80,168],[168,32,168],[40,176,208],[208,200,32],[192,56,24]]; //Colour setup for snacks (in RGB).
//
//LISTENERS
addEventListener("resize",function(){SETSIZE();});  //Auto adjusts the game canvas when the browser window is resized.
addEventListener( //This records when certain keys are pressed down.
    'keydown',function(e){
        if(e.keyCode==32||e.keyCode==37||e.keyCode==38||e.keyCode==39||e.keyCode==40){e.preventDefault()};//This prevents the spacebar and arrow keys from doing their usual thing.
        keyPress[e.keyCode]=true
    }, true
);
addEventListener( //This records when certain keys are released.
    'keyup',function(e){
        keyPress[e.keyCode]=false;
        let i0=keys.length; for(let i=0; i<i0; i++){if(e.keyCode==keys[i]){keyHold[i]=0;}} //If a key is let go, this will free the 'it's held down' value.
    }, true
);
//
//FUNCTIONS
function SETSIZE(){ //Set the size of the screen.
    let i,i2;                                           //Set 'css' size (of the canvas).
    i=(innerHeight-4)/scrWidth,i2=innerWidth/scrHeight; //This will use get the dimensions of the screen as a multiplier of the game screen base size.
    if(i2<i){i=i2}                                      //Whichever is the smaller scaleup value will be used.
    if(i<1){i=1;}                                       //This sets a minimum size.
    GAME.style.width=Math.floor(scrWidth*i)+"px";       //Set the canvas 'css' size.
    GAME.style.height=Math.floor(scrHeight*i)+"px";
}
//
//Keyboard Handling
function KEYBOARD(a){return keyPress[keys[a]]&&keyHold[a]==0;} //Returns true when a key is pressed AND the key was not already held down.
function KEYBOARDHOLD(a){keyHold[a]=1;} //The key is now 'held down'.
//
// -- COPY value
function COPY(a){return JSON.parse(JSON.stringify(a));} //Duplicates the value.
//
// -- Get position of snack
function SNACKSPOT(){
    let x,y,z=1,i0;
    while(z==1){ //This runs while an obstacle prevents the snack from being
                 //generated. This includes walls and body segments.
        z=0,
        x=Math.floor(Math.random()*16),              //Pick a random X & Y spot on the map
        y=Math.floor(Math.random()*14);
        if(mapX[y*16+x]==1){                         //Cancel if this lands on a map tile.
            z=1;
        }
        i0=pBody.length;
        for(let i=0; i<i0; i++){
            if(pBody[i][0]==x && pBody[i][1]==y){    //Cancel is this lands on a body segment.
                z=1;
            }
        }
        if(z==0){                                    //Set the snack co-ordinates and colour, here.
            snack=[x,y,Math.floor(Math.random()*5)];
        }
    }
}
//
// -- Graphics.
function STARTGFX(){
    gfx=GAMEX.getImageData(0,0,scrWidth,scrHeight); //Create an array from the existing screen data.
}
function ENDGFX(){
    GAMEX.putImageData(gfx,0,0); //Draw a page based on the created and modified array data.
    gfx=null;                    //Clears the array.
}
//
// -- Draw some pixels on the screen.
function DRAWBLOCK(x,y,w,h,r,g,b){ //X & Y starting point, width, height, red, green, blue
    let a;
    for(let i=0; i<h; i++){                                          //Loop the width and height to draw a box.
        for(let j=0; j<w; j++){
            if(x+j>=0 && x+j<scrWidth && y+i>=0 && y+i<scrHeight){//Make sure the pixel in question is not out of bounds.
                a=((y*scrWidth)+(i*scrWidth)+x+j)*4;                    //Get the pixel position within the graphics array data.
                gfx.data[a+0]=r;                                        //Update the red, green and blue values.
                gfx.data[a+1]=g;
                gfx.data[a+2]=b;
            }
        }
    }
}
//
// -- Draw some text on the screen.
function TEXT(x,y,a){
    let i0, j0, txtSetup;            //Function-specific variables for looping and the text characters.
    a=a.toString();                  //Make sure that the input is not a number.
    i0=a.length;
    for(let i=0; i<i0; i++){
        txtSetup=fontSetup[a[i]];    //ID the letter
        let j0=txtSetup.length;      //Determine how many times the draw function is being called
        for (let j=0; j<j0; j+=4){//Shadow
            DRAWBLOCK(x+(i*6)+txtSetup[j],y+2+txtSetup[j+1],txtSetup[j+2],txtSetup[j+3],224,224,224);
        }
        for (let j=0; j<j0; j+=4){//Main colour
            DRAWBLOCK(x+(i*6)+1+txtSetup[j],y+1+txtSetup[j+1],txtSetup[j+2],txtSetup[j+3],48,48,48);
        }
    }
}
//
// -- Start the game.
function STARTGAME(){
    pBody=[[7,8]], //We start with the head at a specific X & Y.
    pCollect=0,
    pDeaths=0,
    pDir="x",
    pLifeScore=250,
    pLifeBase=250,
    pLifeInc=250,
    pLives=2,
    pRound=0,
    pSegMax=5,
    pScore=0,
    pSnack=5,
    pTimer=0;
    //
    endRound=0,
    gTimer=0,
    mapX=COPY(mapSrc[0]),
    tempDir=4;
    SNACKSPOT();
    //
    requestAnimationFrame(ACTION); //Fire up the game.
}
//
// -- Main game cycle
function ACTION(){
    let x, y, bL;  //Useful in-function variables: X&Y positions | body Length
    //Keyboard controls
    if(KEYBOARD(0)){}      //Left
    else if(KEYBOARD(1)){} //Up
    else if(KEYBOARD(2)){} //Right
    else if(KEYBOARD(3)){} //Down
    if(KEYBOARD(4)){}      //Pause Game?
     //Graphics
    GAMEX.fillStyle=`rgb(255,255,255)`; //Beginning drawing, starting with a white background
    GAMEX.fillRect(0,0,scrWidth,scrHeight);
    STARTGFX();
    for(let y=0; y<14; y++){            //Draw the map tiles; 16x14 grid.
        for(let x=0; x<16; x++){
            if(mapX[(y*16)+x]==1){DRAWBLOCK(x*8,(y*8)+16,8,8,48,48,48);}
        }
    }
    if(snack[0]>=0){                    //Should the snack be drawn?
        DRAWBLOCK(snack[0]*8+1,snack[1]*8+17,5,5,snackColors[snack[2]][0],snackColors[snack[2]][1],snackColors[snack[2]][2]);
    }
    bL=pBody.length;
    for(let y=bL-1; y>=0; y--){         //Worm. Check is included to see if the head, body or tail is being
                                        //drawn.  We do this backwards, so the head is drawn last.
    if(pBody[y][1]>=0){
            if(y==0 && endRound==0){DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,224,128,48);}  //Head. Do not draw this if the head is outside the room.
            else if (y==bL-1){DRAWBLOCK(pBody[y][0]*8+1,pBody[y][1]*8+17,5,5,80,192,64);}       //Tail
            else {DRAWBLOCK(pBody[y][0]*8,pBody[y][1]*8+16,7,7,80,192,64);}                     //Body
        }
    }
    TEXT(0,0,"LIVES:"+pLives);                          //Top line details
    TEXT(0,8,"ROUND:"+(pRound+1));
    let p=pScore.toString(); while(p.length<5){p="0"+p;}
    TEXT(60,0,"SCORE:"+p);
    TEXT(60,8,"SNACKS:"+pSnack);
    ENDGFX();                                           //Draw the graphics.
    //
    requestAnimationFrame(ACTION);
}
//
//EXECUTE THE GAME
GAME.width=scrWidth;   //Set 'canvas' size.
GAME.height=scrHeight;
SETSIZE();             //Set 'css' size.
STARTGAME();           //Get the game underway.

Leave a comment

Log in with itch.io to leave a comment.