In prior parts we setup a Node.js/Express server to serve a cocos2D HTML project, showed how to host that project in the cloud using Heroku, then we created the backbone of the application itself, providing the ability to populate the app with data, as well as a means to retrieve that data.
Now, it’s time to actually create the game itself.
As I mentioned at the very beginning, the game is remarkably simple and isn’t really a game at all. It is a simple series of two configurable pictures, it shows both side by side, then if you click it one zooms in, then if you click again, the next image zooms in, then finally, both images are shown side by side again. The end result is I can show my daughter a sequence of events so she can better understand cause and effect.
Here is a screenshot of our ultimate end result:
Not earth shattering by any means, but it a) accomplishes what I need to accomplish and can be hosted on any device and accessed anywhere I need it b) demonstrates all of the core technologies needed to make a much more complex web hosted game or web application.
Lets jump right in and look at the code. The graphics are powered by cocos2D HTML, a 2D JavaScript based game library. You can read some tutorials about it here, as I am not going to get into how cocos2D works in any detail.
First we need our AppDelegate.js class, which fires up our cocos2D game.
var cc = cc || {}; cc.AppDelegate = cc.Application.extend({ ctor:function () { this._super(); }, initInstance:function () { return true; }, applicationDidFinishLaunching:function () { var pDirector = cc.Director.sharedDirector(); var size = pDirector.getWinSize(); pDirector.setAnimationInterval(1.0 / 60); var pScene = FirstThis.scene(); pDirector.runWithScene(pScene); return true; }, applicationDidEnterBackground:function () { cc.Director.sharedDirector().pause(); }, applicationWillEnterForeground:function () { cc.Director.sharedDirector().resume(); } });
For more information on what is happening here, check the tutorial link I posted earlier. The most important part to us is the creation of FirstThis, which is the heart of our application.
Let’s take a look at FirstThis.js. Again, I apologize for the wonky formatting, it was done to fit the blog.
var FirstThis = cc.LayerColor.extend({ leftSprite:null, rightSprite:null, mode:0, imageChanged:function(imgName,whichSprite){ if(this.mode != 0) // We were in transition when select box was changed! { this.resetVisibility(); this.mode=0; } this.removeAllChildrenWithCleanup(true); if(this.leftSprite != null && whichSprite=="right") { this.addChild(this.leftSprite); } if(this.rightSprite != null && whichSprite=="left"){ this.addChild(this.rightSprite); } var imageSize; YUI().use('node','io-base',function(Y){ var results = Y.io("/imageSize/" + imgName, {"sync":true}); imageSize = JSON.parse(results.responseText); }); var newSpriteWidth = cc.Director.sharedDirector().getWinSize().width/2; var newSpriteHeight = cc.Director.sharedDirector().getWinSize().height/2; if(whichSprite == "left"){ this.leftSprite = cc.Sprite.create("/image/" + imgName, new cc.Rect(0,0,imageSize.width,imageSize.height)); this.addChild(this.leftSprite); this.leftSprite.setScale( (newSpriteWidth * this.leftSprite.getScaleX())/imageSize.width); this.leftSprite.setAnchorPoint(new cc.Point(0,1)); this.leftSprite.setPosition( new cc.Point(0,cc.Director.sharedDirector().getWinSize().height)); } else { this.rightSprite = cc.Sprite.create("/image/" + imgName, new cc.Rect(0,0,imageSize.width,imageSize.height)); this.addChild(this.rightSprite); this.rightSprite.setScale( (newSpriteWidth * this.rightSprite.getScaleX())/imageSize.width); this.rightSprite.setAnchorPoint(new cc.Point(0,1)); this.rightSprite.setPosition( new cc.Point(newSpriteWidth,cc.Director.sharedDirector().getWinSize().height)); } }, resetVisibility:function() { this.leftSprite.setIsVisible(true); this.rightSprite.setIsVisible(true); this.leftSprite.setPosition( new cc.Point(0,cc.Director.sharedDirector().getWinSize().height)); this.rightSprite.setPosition( new cc.Point(cc.Director.sharedDirector().getWinSize().width/2, cc.Director.sharedDirector().getWinSize().height)); }, ctor:function() { this._super(); }, init:function() { this.setIsTouchEnabled(true); this.initWithColor(cc.ccc4(0,0,0,255)); var that = this; YUI().use('node',function(Y){ Y.one("#firstSel").on("change",function(event){ if(event.currentTarget.get("selectedIndex") == 0) return; that.imageChanged(event.currentTarget.get("value"),"left"); }); Y.one("#thenSel").on("change",function(event){ if(event.currentTarget.get("selectedIndex") == 0) return; that.imageChanged(event.currentTarget.get("value"),"right"); }); }); this.setAnchorPoint(0,0); return this; }, ccTouchesEnded:function (pTouch,pEvent){ if(this.leftSprite != null && this.rightSprite != null ){ this.mode++; if(this.mode == 1) { this.leftSprite.setIsVisible(true); this.rightSprite.setIsVisible(false); this.leftSprite.setPosition( new cc.Point(cc.Director.sharedDirector().getWinSize().width/4, cc.Director.sharedDirector().getWinSize().height)); } else if(this.mode == 2) { this.leftSprite.setIsVisible(false); this.rightSprite.setIsVisible(true); this.rightSprite.setPosition( new cc.Point(cc.Director.sharedDirector().getWinSize().width/4, cc.Director.sharedDirector().getWinSize().height)); } else{ this.resetVisibility(); this.mode = 0; } } } }); FirstThis.scene = function() { var scene = cc.Scene.create(); var layer = FirstThis.layer(); scene.addChild(layer); return scene; } FirstThis.layer = function() { var pRet = new FirstThis(); if(pRet && pRet.init()){ return pRet; } return null; }
Again, most of what is going on here is covered in the earlier tutorial, but I will give a quick overview, and point out some of the application specific oddities as I go.
Starting from the top, we declare a pair of variables for holding our two active sprites, as well as a value for the current “mode”, which essentially represents our click state, which will make sense shortly.
We then define a method imageChanged which will be called when the user selects a different value in one of the two select boxes at the top of the screen. On change we first chack to see if the mode is not 0, which means that we are in the process of showing images to the user ( meaning a single image might be visible right now ), in which case we reset visibility and positioning so our two images are side by side again, and reset the mode back to zero. Then we remove all of the sprites from the layer, effectively erasing the screen.
Next we want to check if the user is updating the left or the right image. If the user is updating the right image for example, we check to see if the left image has been assigned a value yet, and if it has assign it back to the scene. This test is to prevent trying to push a null sprite onto the scene if the user hasn’t selected images with both drop-downs yet. We do this for the left and right image.
Next we run into a bit of a snag. This application wants to size images so they each take up 50% of the screen. There is a problem with this however, as when you declare a sprite with cocos2D, unless you specify the image size, it doesn’t have any dimension data! This is ok with a game when you will probably know the image dimensions in advance, but in this situation where the images are completely dynamic, it presents a major problem! This is a problem we solved rather nicely on the server side using node. We will look at the changes to server.js shortly, but for now realize we add a web service call that returns the specified image dimensions. We then make a synchronous call using YUI’s io() module to retrieve that information… note the complete lack of error handling here! My bad.
We ideally want to scale our image so their width is half the screen. We now create our sprite, depending if it is on the left or right side, but the logic is basically identical. First we create the sprite using the filename passed as a value from our select box, and the dimensions we fetched earlier. We then add that file to the scene, scale it so the width is 50% of the screen ( scaling it up or down as required ), then position it relative to the top left corner of the sprite, with the X value changing if the sprite is on the left or right.
Next up we create the function resetVisibility() which we used earlier on. Basically it just makes both sprites visible again and puts them back in their default positions. Next we implement a simple constructor that calls layers constructor. This is to verify some methods needed to handle touch input are properly called. The cocos2D tutorial on handling input covers this in a bit more detail.
Next up is our initialization function, named appropriately enough init(). We tell cocos2D that we are going to handle touch (click) events, that we want to create a layer with a black opaque background. Next we wire up event handlers to our two select boxes that call our imageChanged() method when, um, and image is changed. Lastly we tell our layer to anchor using the default bottom left corner. This call is rather superfluous, as this is the default. I like to include it for peace of mind though, as if there is something I fight with ( and hate! ) about cocos, it’s the coordinate system.
Next up we have our touch handler ccTouchesEnded, which will be called when the user taps the screen or when they click a mouse. This is where the mode variable comes into play. At a value of 0, it means the screen hasn’t been touched yet, so on first touch, we set the left image to the center of the screen and the right image as invisible. On the next touch, we do the opposite, then on any further touches we set both images back to their default positions and reset mode back to zero. The remaining code is simple boiler plate setup code used to create our layer, which again is covered in more detail in these tutorials.
In a nutshell, that is our game’s code. You may remember earlier we made a change to server.js, let’s take a look at the file now.
Here is server.js in it’s entirety. Remember, you do not need to host on Heroku to run this app. Simple run node server.js from the command line or terminal, then hit localhost:3000 in your web browser.
server.js
var express = require('express'), server = express.createServer(), im = require('imagemagick'), files = {}; server.use('/cocos2d', express.static(__dirname + '/cocos2d') ); server.use('/cocosDenshion', express.static(__dirname + '/cocosDenshion') ); server.use('/classes', express.static(__dirname + '/classes') ); server.use('/resources', express.static(__dirname + '/resources') ); server.use(express.bodyParser()); server.get('/', function(req,res){ res.sendfile('index.html'); console.log('Sent index.html'); }); server.get('/settings',function(req,res){ res.sendfile('settings.html'); console.log('Send settings.html'); }); // API calls server.get('/image/:name', function(req,res){ if(files[req.params.name]) { res.contentType(files[req.params.name].contentType); res.sendfile(files[req.params.name].path); console.log("Returning file" + req.params.name); } }); server.get('/imageSize/:name',function(req,res){ im.identify(files[req.params.name].path,function(err,features){ console.log("image/" + req.params.name); if(err) throw err; else res.json({ "width":features.width, "height":features.height }); }); }); server.get('/getPhotos', function(req,res){ res.json(files); }); server.get('/clearAll', function(req,res){ files = {}; res.statusCode = 200; res.send(""); }) server.post('/upload',function(req,res){ files[req.files.Filedata.name] = { "name":req.files.Filedata.name, "path":req.files.Filedata.path, "size":req.files.Filedata.size, "contentType":req.files.Filedata.type, "description":req.body.description }; console.log(req.files.Filedata); console.log(Object.keys(files).length); res.statusCode = 200; res.send(""); }); server.listen(process.env.PORT || 3000);
Most of this code we have seen before, but there are a couple of new changes. First thing to notice is:
im = require(‘imagemagick’),
We added a new library to the mix to handle image process, the venerable ImageMagick. You need to install this library before running this code. First we need to add it to node, that is as simple as, from a command line, cd to your project directory then type:
npm install imagemagick
If you are deploying to Heroku, you also need to update the dependencies in the package.json file, so Heroku is aware of the dependencies. That file should now look like:
{ "name": "firstthis", "version": "0.0.1", "dependencies": { "express": "2.5.x", "imagemagick":"0.1.x" }, "engines": { "node": "0.8.x", "npm": "1.1.x" } }
Finally, you need to install ImageMagick itself. On Windows the easiest way is to download the binary installer, while for Linux it’s probably easiest to use a package manager of your choice. Once you install imagemagick, be sure to start a new command line/terminal so it picks up the path variables image magick sets.
Ok, now that we have the dependency out of the way, the code itself is trivial:
server.get('/imageSize/:name',function(req,res){ im.identify(files[req.params.name].path,function(err,features){ console.log("image/" + req.params.name); if(err) throw err; else res.json({ "width":features.width, "height":features.height }); }); });
We get file details about the image passed in to the url ( for example with the URL /imageSize/imagename.jpg, name will = imagename.jpg ), then if no errors occur, we return the width and height as a JSON response.
Finally, we get to the actual HTML, index.html which is served by Node if you request the “/” of the website.
<html> <head> <script src="http://yui.yahooapis.com/3.5.1/build/yui/yui-min.js"></script> <script> YUI().use('node','io-base',function(Y){ Y.on("load", function(){ var canvas = Y.DOM.byId('gameCanvas'); canvas.setAttribute("width",window.innerWidth-30); canvas.setAttribute("height", window.innerHeight-70); Y.Get.script(['/classes/cocos2d.js']); }); Y.io('/getPhotos',{ on: { complete:function(id,response){ var files = JSON.parse(response.responseText); var firstSel = Y.DOM.byId("firstSel"); var thenSel = Y.DOM.byId("thenSel"); for(var key in files) { firstSel.options.add( new Option(files[key].description,files[key].name)); thenSel.options.add( new Option(files[key].description,files[key].name)); } } } }); }); </script> </head> <body style="padding:0; margin: 0; background: black"> <form> <span style="align:left;vertical-align:top;padding-top:0px"> <label style="color:white;height:40px;font-size:26;vertical-align:middle"> First: </label> <select style="height:40px;font-size:22;width:250" id="firstSel"> <option selected>Drop down to choose</option> </select> <label style="color:white;height:40px;font-size:26; padding-left:10px;vertical-align: middle;">Then:</label> <select style="height:40px;font-size:22;width:250" id="thenSel"> <option>Drop down to choose</option> </select> </span> <span style="float:right;vertical-align: top;margin-top:0px;top:0px;"> <input align=right type=button value=settings id=settings style="height:40px;font-size:26" onclick="document.location.href='/settings';" /> </span> </form> <div width=100% style="text-align:center;clear:both;"> <canvas id="gameCanvas"> Your browser does not support the canvas tag </canvas> </div> </body> </html>
Everything here we have seen before, except perhaps this small chunk of extremely important code:
Y.on("load", function(){ var canvas = Y.DOM.byId('gameCanvas'); canvas.setAttribute("width",window.innerWidth-30); canvas.setAttribute("height", window.innerHeight-70); Y.Get.script(['/classes/cocos2d.js']); });
This code will be executed once the page finishes loading. As you may notice, the canvas tag gameCanvas did not have a size specified, as we want it to take the full width of the device it is run on. Unfortunately you cannot just say width=100% and be done with it, so we set the width and height programmatically when the page loads. Finally, to make sure that cocos2D objects aren’t loaded and set until after the Canvas tag is resized, I deferred the loading until now, so after we resize the canvas, we dynamically load the cocos2d script, making sure it initializes with the proper dimensions.
So, after all our hard work, here is our running completed application in action. ( Or direct link here, if you don’t want to see it in an iframe ). Again a warning the data for this application is completely viewer driven, I give no guarantees the content is appropriate! Please be civil ).
That basically completes the application tutorial. There are a few issues right now:
1- It doesn’t persist user data. If the server restarts or a certain period of time elapses, all the data stored on Heroku is lost. This is easily fixed, but beyond the scope of this tutorial
2- There is a complete lack of hardening or error handling, so the application is probably incredibly fragile
3- There are a few HTML bugs ( oh… aren’t there always? ). The change event doesn’t always fire when you select the first drop down the first time ( this is a browser bug, and can be worked around, but the code isn’t tutorial friendly. ). Also, on Safari mobile, cocos2D sprite scaling doesn’t appear to work.
I hope you found this series useful. As I was working with git anyways for the Heroku deployment, I decided to make this entire project available on github. So if you want to fork it and play around, have fun! I am a massive github newbie though, so don’t be shocked if I screwed something up. If you, like me, have a child that isn’t doing transitions very well, I hope you find it useful. I am actually making a more production worth version of this application, so if you need an online first-then board, drop me a line!
On a final note, I am going to look into making a PhoneGap version of this application. If that exercise bares fruit, I will have another section for this tutorial!