Read my guest post on Mozilla Hacks
Check out The Pond (Source - GPL) on:
Google Play Chrome Web Store Amazon App Store Firefox Marketplace
also on clay.io, FB (http), Pokki
Tools used: LightTable IDE, CocoonJS
The Pond - because quality trumps quantity.
Before I go into some of the highlights of The Pond in code, I would like to give credit to both LightTable and CocoonJS for making the experience so enjoyable and awesome.
LightTable is a javascript IDE which started out as a concept video, and turned into a successful Kickstarter. The most amazing feature of LightTable is it's code injection - as you edit javascript, you can update the javascript interpreted by V8 in the browser in real-time (video), which is an extremely powerful tool when developing a game where dealing with full page reloads can be cumbersome.
CocoonJS is a mobile platform for deploying HTML5 canvas games to mobile. It has implemented the javascript canvas in OpenGL for faster performance on mobile, and it also makes deploying the application to many platforms extremely simple (just tick a few check-boxes and you get a compiled version of the app for 4 different platforms).
One of the most important features of The Pond is that it's completely fluid and dynamic. No matter what screen size, be it a full screen desktop or low resolution mobile, The Pond dynamically adjusts itself flawlessly. Additionally, on mobile it will automatically reduce render quality if it detects a lag in frame rate. It is able to accomplish these things in part because it uses a proper game loop (from this amazing article on game loops):
var MS_PER_UPDATE = 18 var lag = 0.0 var previousTime = 0.0 // main game loop function draw(time) { requestAnimFrame(draw) lag += time - previousTime previousTime = time var MAX_CYCLES = 18 while(lag >= MS_PER_UPDATE && MAX_CYCLES) { // user input, movement, and animation calculations physics() lag -= MS_PER_UPDATE MAX_CYCLES-- } // if we exhausted our cycles, the client must be lagging if(MAX_CYCLES === 0) { // adaptive quality lowerQuality() } // if 5 frames behind after update, jump if(lag/MS_PER_UPDATE > 75) { lag = 0.0 } // draw to canvas paint() }
The key to this game loop is that it uses a fixed interval physics time-step, and renders to the screen whenever possible. This means that all physics calculations are constant and run in predictable time. This means a fast computer will see the same physics as a slower computer (or in this case mobile device). Then, after the physics has been synced properly (at 60FPS), the actual screen drawing is done. This means that a slower computer will have a lower frame-rate for paint updates (30fps vs 60fps for example), which makes sense because the computer should lag but not break (due to physics time-step differences).
Debug Mode |
Fish.prototype.collide = function (fish) { // there are 6 circles that make up the collision box of each fish // check if they collide var c1, c2 for (var i=-1, l = this.circles.length; ++i<l;) { c1 = this.circles[i] for (var j=-1, n = fish.circles.length; ++j < n;) { c2 = fish.circles[j] // check if they touch if(distance(c1, c2) <= c2.r + c1.r) { return true } } } return false }
Another challenge I faced was dealing with rendering performance on mobile. The most expensive part of the whole painting operation (which was the bottleneck) was drawing the curves for each fish every frame. Normally most application use a sprite sheet to handle character animation (see Senshi), however The Pond has many dynamic elements in terms of color and shape based on rotation speed which make using a sprite sheet extremely difficult. So instead of using a sprite sheet, I draw each fish as a combination of Bézier curves.
Fish.prototype.drawBody = function() { var fish = this var size = this.size var ctx = this.ctx var curv = this.curv var o = this.oscillation ctx.strokeStyle = fish.bodyOutline ctx.lineWidth = 4 for(var i = -1; i < 2; i+=2){ var start = { x: size, y: 0 } var c1 = { x: size * (14/15), y: i*size + size/30*o + curv/3 } var c2 = { x: -size/2, y: i*size + size/30*o + curv/2 } var end = { x: -size*2, y: i*size/3 + size/15*o + curv } ctx.moveTo(start.x, start.y) ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, end.x, end.y) var c3 = { x: -size * 2.5, y: i*size/6 + size/10*o + curv } var c4 = { x: -size*3, y: i*size/4 - size/15*o + curv/2 } var end2 = { x: -size*3, y: -size/15*o + curv/3 } ctx.bezierCurveTo(c3.x, c3.y, c4.x, c4.y, end2.x, end2.y) } ctx.stroke() }
Now, this code could be optimized slightly by removing the new objects being created ({} generates a new object), however based on testing the biggest performance culprit is the bezierCurveTo() call, and having clean code takes priority over a micro-optimization. `this.oscillation` is based on a sin wave, and `this.curv` is based on distance to current rotation target. Overall, I was quite pleased with the rendering performance of the app. For more details, check out the commit log on github where you can find the commits which made the biggest performance improvements.
Lastly, I had a good bit of trouble figuring out how to get the fish to turn toward a target direction in the most efficient manner (take the shortest path around the unit circle). I eventually came up with this:
function directionTowards(a, b) { return Math.atan2(a.y-b.y, a.x-b.x) } var dir = this.dir var targetDir = directionTowards(MOUSE, this) var arcSpeed = this.arcSpeed // should it turn left or right? (based on shortest distance) var arcDirection = 1 // if the arc distance is greater than 180deg, turn the other way if(Math.abs(dir-targetDir)>Math.PI) { arcDirection = -1 } // prevent over-turning var moveDistance = Math.min(arcSpeed, Math.abs(dir-targetDir)) // do the actual rotation this.dir += (dir > targetDir ? -1 : 1) * arcDirection * moveDistance // normalize dir within range( -PI < dir < PI ) if(this.dir>Math.PI) { this.dir = this.dir - Math.PI*2 } else if(this.dir<-Math.PI) { this.dir = this.dir + Math.PI*2 }
Bonus: I tried to integrate a metaballs effect into the game, but it didn't work out. Check out this jsfiddle for a great example of metaballs (blog post).