Posted October 26, 2023 by KuroGamedev
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:
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.
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); } } }
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); }
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:
//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.
//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();
//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.
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.