3D Arcade Simplex Game: Consistency Refactoring
INTRODUCTION
Usually when you’re writing code for fun — off the top of your head (aka code noodling, code jamming, code improvisation) — your main concern is with things working. In the case of our simple game, “working” means everything moves and fires correctly.
Now, once everything works, your next step is to do what’s known as code refactoring. Basically this means the functionality stays the same, but the code is easier to read, easier to maintain, more consistent, and even more efficient—to name just a few ways of refactoring. Let’s do this.
ORIGINAL UNREFACTORED SOURCE CODE
<!doctype html> <html> <head> <script src="babylon.max.js"></script> <script> window.addEventListener("DOMContentLoaded", init); window.addEventListener("keydown", herocontrol); function init() { canvas=document.getElementById("cvGame"); engine=new BABYLON.Engine(canvas); scene=new BABYLON.Scene(engine); light=new BABYLON.HemisphericLight("sun", new BABYLON.Vector3(0,1,0), scene); ground=BABYLON.MeshBuilder.CreateGround("mGround", {width:1000,height:1000}, scene); ground.material = new BABYLON.StandardMaterial("gMat", scene); ground.material.diffuseColor=new BABYLON.Color3(0,.5,0); hero = BABYLON.MeshBuilder.CreateCylinder("cHero", {height:10,diameterBottom:10,diameterTop:0}, scene); hero.material=new BABYLON.StandardMaterial("mHero", scene); hero.material.diffuseColor=new BABYLON.Color3(0,0,1); hero.position=new BABYLON.Vector3(-20, 5, 0); hero.rotation.z=-Math.PI/2; hero.computeWorldMatrix(true); mons= BABYLON.MeshBuilder.CreateCylinder("cMons", {height:10,diameterBottom:10,diameterTop:0}, scene); mons.material=new BABYLON.StandardMaterial("mMons", scene); mons.material.diffuseColor=new BABYLON.Color3(1,0,0); mons.position=new BABYLON.Vector3(20, 5, 0); mons.rotation.z=-Math.PI/2; mons.computeWorldMatrix(true); boom = BABYLON.MeshBuilder.CreateCylinder("cBoom", {height:10,diameterBottom:4,diameterTop:0}, scene); boom.material=new BABYLON.StandardMaterial("mBoom", scene); boom.material.diffuseColor=new BABYLON.Color3(1,.5,0); boom.position=new BABYLON.Vector3(0,5,20); boom.rotation.z=-Math.PI/2; boomdx=boomdz=0; boom.computeWorldMatrix(true); camera =new BABYLON.FreeCamera("mCam", new BABYLON.Vector3(0,6,-50), scene); camera.attachControl(canvas); gameover=false; engine.runRenderLoop(gameloop); } function gameloop() { if (!gameover) { monsdx=hero.position.x-mons.position.x; monsdz=hero.position.z-mons.position.z; monsang=Math.atan2(monsdz,monsdx); monsspd=0.05; mons.position.x+=Math.cos(monsang)*monsspd; mons.position.z+=Math.sin(monsang)*monsspd; mons.rotation.y=-monsang; if (mons.intersectsMesh(hero,true)) { gameover=true; spWinLose.innerHTML="YOU DIED!"; } boom.position.x+=boomdx; boom.position.z+=boomdz; if (boom.intersectsPoint(mons.position)) { gameover=true; spWinLose.innerHTML="YOU WIN"; } } scene.render(); } function herocontrol(event) { switch (event.key) { case 'a': hero.rotation.y-=Math.PI/180; break; case 'd': hero.rotation.y+=Math.PI/180; break; case 'w': heroang=-hero.rotation.y; herospd=1; herodx=Math.cos(heroang)*herospd; herodz=Math.sin(heroang)*herospd; hero.position.x+=herodx; hero.position.z+=herodz; break; case 's': heroang=-hero.rotation.y; herospd=1; herodx=Math.cos(heroang)*herospd; herodz=Math.sin(heroang)*herospd; hero.position.x-=herodx; hero.position.z-=herodz; break; case 't': camera.position.y=200; camera.position.x=camera.position.z=0; camera.rotation.x=Math.PI/2; break; case 'Enter': boom.position.x=hero.position.x; boom.position.z=hero.position.z; boom.rotation.y=hero.rotation.y; boomang=-boom.rotation.y; boomspd=1; boomdx=Math.cos(boomang)*boomspd; boomdz=Math.sin(boomang)*boomspd; break; } } </script> <style> body {width:100%; height:100%; padding:0; margin:0; background:red;} #cvGame {position:absolute;width:100%; height:100%; padding:0; margin:0; background:skyblue;} </style> </head> <body> <canvas id="cvGame"></canvas> <span id="spWinLose" style="position:absolute;bottom:0;right:0;">SIMPLEX</span> </body> </html>
Explanation. I’ve bolded the code that are candidates for refactoring.
Briefly, in refactoring, one of the first and easiest things I look for are variables that are initialized to constant values such as boomspd=1. This code should be moved to the init function.
I also look for code that operates similarly. For example, the code that updates *.position.x and *.position.z is roughly the same for the hero, the monster, and the missile. They’re all variations of adding a dx and dz, multiplied by a speed. They should all be rewritten to use a consistent formula:
position.x=dx*spd;
position.z=dz*spd;
Here’s one way.
STEP 7: REFACTOR FOR INITIALIZATION AND CONSISTENCY
<!doctype html> <html> <head> <script src="babylon.max.js"></script> <script> window.addEventListener("DOMContentLoaded", init); window.addEventListener("keydown", herocontrol); function init() { canvas=document.getElementById("cvGame"); engine=new BABYLON.Engine(canvas); scene=new BABYLON.Scene(engine); light=new BABYLON.HemisphericLight("sun", new BABYLON.Vector3(0,1,0), scene); ground=BABYLON.MeshBuilder.CreateGround("mGround", {width:1000,height:1000}, scene); ground.material = new BABYLON.StandardMaterial("gMat", scene); ground.material.diffuseColor=new BABYLON.Color3(0,.5,0); hero = BABYLON.MeshBuilder.CreateCylinder("cHero", {height:10,diameterBottom:10,diameterTop:0}, scene); hero.material=new BABYLON.StandardMaterial("mHero", scene); hero.material.diffuseColor=new BABYLON.Color3(0,0,1); hero.position=new BABYLON.Vector3(-20, 5, 0); hero.rotation.z=-Math.PI/2; herodx=herodz=0; herospd=1; herorotspd=5; herohit=herodead=false; // for future version hero.computeWorldMatrix(true); mons= BABYLON.MeshBuilder.CreateCylinder("cMons", {height:10,diameterBottom:10,diameterTop:0}, scene); mons.material=new BABYLON.StandardMaterial("mMons", scene); mons.material.diffuseColor=new BABYLON.Color3(1,0,0); mons.position=new BABYLON.Vector3(20, 5, 0); mons.rotation.z=-Math.PI/2; monsdx=monsdz=0; monsspd=0.05; monsrotspd=1; // monsrotspd just in case monshit=monsdead=false; // for future version mons.computeWorldMatrix(true); boom = BABYLON.MeshBuilder.CreateCylinder("cBoom", {height:10,diameterBottom:4,diameterTop:0}, scene); boom.material=new BABYLON.StandardMaterial("mBoom", scene); boom.material.diffuseColor=new BABYLON.Color3(1,.5,0); boom.position=new BABYLON.Vector3(0,5,20); boom.rotation.z=-Math.PI/2; boomdx=boomdz=0; boomspd=boomrotspd=1; // boomrotspd just in case boomhit=boomdead=false; // for future version boom.computeWorldMatrix(true); camera =new BABYLON.FreeCamera("mCam", new BABYLON.Vector3(0,6,-50), scene); camera.attachControl(canvas); gameover=false; engine.runRenderLoop(gameloop); } function gameloop() { if (!gameover) { monsdx=hero.position.x-mons.position.x; monsdz=hero.position.z-mons.position.z; monsang=Math.atan2(monsdz,monsdx); // monsspd=0.05; move to init() monsdx=Math.cos(monsang); // added monsdz=Math.sin(monsang); // added mons.position.x+=monsdx*monsspd; // replaced Math.cos w/monsdx mons.position.z+=monsdz*monsspd; // replaced Math.sin w monsdz mons.rotation.y=-monsang; if (mons.intersectsMesh(hero,true)) { gameover=true; spWinLose.innerHTML="YOU DIED!"; } // boomspd=1; // added but init'ed at top boom.position.x+=boomdx*boomspd; // added boomspd boom.position.z+=boomdz*boomspd; // added boomspd if (boom.intersectsPoint(mons.position)) { gameover=true; spWinLose.innerHTML="YOU WIN"; } } scene.render(); } function herocontrol(event) { switch (event.key) { case 'a': hero.rotation.y-=Math.PI/180*herorotspd; // added *herorotspeed also set in init break; case 'd': hero.rotation.y+=Math.PI/180*herorotspd; // added *herorotspeed also set in init break; case 'w': heroang=-hero.rotation.y; herospd=1; herodx=Math.cos(heroang); // moved *herospd herodz=Math.sin(heroang); // moved *herospd hero.position.x+=herodx*herospd; // moved it here hero.position.z+=herodz*herospd; // moved it here break; case 's': heroang=-hero.rotation.y; herospd=1; herodx=Math.cos(heroang); // moved *herospd herodz=Math.sin(heroang); // moved *herospd hero.position.x-=herodx*herospd; // moved it here hero.position.z-=herodz*herospd; // moved it here break; case 't': camera.position.y=200; camera.position.x=camera.position.z=0; camera.rotation.x=Math.PI/2; break; case 'Enter': boom.position.x=hero.position.x; boom.position.z=hero.position.z; boom.rotation.y=hero.rotation.y; boomang=-boom.rotation.y; // boomspd=1; // now set in gameloop boomdx=Math.cos(boomang); // *boomspd; now done in gameloop boomdz=Math.sin(boomang); // *boomspd; now done in gameloop break; } } </script> <style> body {width:100%; height:100%; padding:0; margin:0; background:red;} #cvGame {position:absolute;width:100%; height:100%; padding:0; margin:0; background:skyblue;} </style> </head> <body> <canvas id="cvGame"></canvas> <span id="spWinLose" style="position:absolute;bottom:0;right:0;">SIMPLEX</span> </body> </html>
Explanation. I moved any statements with hard-coded values (like boomspd=1) to the init(). I also added new variables like monsrotspd, boomrotspd, even though the original code never used them. I did this so that all things in the game have consistent variables, since this makes the game more flexible. Who knows? In a future update we may want to control how fast monsters rotate. For the same flexibility reason, I added *hit and *dead variables. Based on my experience, these two boolean (true/false) variables are extremely useful for playing different animations when hit or dead.
The other major change was to make all position updates the same. Hero, monster, and missile all follow a variation of the equation:
position.x=dx*spd;
position.z=dz*spd;
Why is this refactored code better? Two main reasons. First, because you can change a lot of the game properties in one place: the init function. For example you can change hero, monster, and missile speeds all in one convenient function: init(). But second, because it makes it easier for us to create objects.