Update To 11.40.268.0
[platform/framework/web/crosswalk.git] / src / chrome / renderer / resources / offline.js
1 // Copyright (c) 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 (function() {
5 'use strict';
6 /**
7  * T-Rex runner.
8  * @param {string} outerContainerId Outer containing element id.
9  * @param {object} opt_config
10  * @constructor
11  * @export
12  */
13 function Runner(outerContainerId, opt_config) {
14   // Singleton
15   if (Runner.instance_) {
16     return Runner.instance_;
17   }
18   Runner.instance_ = this;
19
20   this.outerContainerEl = document.querySelector(outerContainerId);
21   this.containerEl = null;
22   this.detailsButton = this.outerContainerEl.querySelector('#details-button');
23
24   this.config = opt_config || Runner.config;
25
26   this.dimensions = Runner.defaultDimensions;
27
28   this.canvas = null;
29   this.canvasCtx = null;
30
31   this.tRex = null;
32
33   this.distanceMeter = null;
34   this.distanceRan = 0;
35
36   this.highestScore = 0;
37
38   this.time = 0;
39   this.runningTime = 0;
40   this.msPerFrame = 1000 / FPS;
41   this.currentSpeed = this.config.SPEED;
42
43   this.obstacles = [];
44
45   this.started = false;
46   this.activated = false;
47   this.crashed = false;
48   this.paused = false;
49
50   this.resizeTimerId_ = null;
51
52   this.playCount = 0;
53
54   // Sound FX.
55   this.audioBuffer = null;
56   this.soundFx = {};
57
58   // Global web audio context for playing sounds.
59   this.audioContext = null;
60
61   // Images.
62   this.images = {};
63   this.imagesLoaded = 0;
64   this.loadImages();
65 }
66 window['Runner'] = Runner;
67
68
69 /**
70  * Default game width.
71  * @const
72  */
73 var DEFAULT_WIDTH = 600;
74
75 /**
76  * Frames per second.
77  * @const
78  */
79 var FPS = 60;
80
81 /** @const */
82 var IS_HIDPI = window.devicePixelRatio > 1;
83
84 /** @const */
85 var IS_MOBILE = window.navigator.userAgent.indexOf('Mobi') > -1;
86
87 /** @const */
88 var IS_TOUCH_ENABLED = 'ontouchstart' in window;
89
90 /** @const */
91 var IS_IOS = window.navigator.userAgent.indexOf('CriOS') > -1;
92
93 /**
94  * Default game configuration.
95  * @enum {number}
96  */
97 Runner.config = {
98   ACCELERATION: 0.001,
99   BG_CLOUD_SPEED: 0.2,
100   BOTTOM_PAD: 10,
101   CLEAR_TIME: 3000,
102   CLOUD_FREQUENCY: 0.5,
103   GAMEOVER_CLEAR_TIME: 750,
104   GAP_COEFFICIENT: 0.6,
105   GRAVITY: 0.6,
106   INITIAL_JUMP_VELOCITY: 12,
107   MAX_CLOUDS: 6,
108   MAX_OBSTACLE_LENGTH: 3,
109   MAX_SPEED: 12,
110   MIN_JUMP_HEIGHT: 35,
111   MOBILE_SPEED_COEFFICIENT: 1.2,
112   RESOURCE_TEMPLATE_ID: 'audio-resources',
113   SPEED: 6,
114   SPEED_DROP_COEFFICIENT: 3
115 };
116
117
118 /**
119  * Default dimensions.
120  * @enum {string}
121  */
122 Runner.defaultDimensions = {
123   WIDTH: DEFAULT_WIDTH,
124   HEIGHT: 150
125 };
126
127
128 /**
129  * CSS class names.
130  * @enum {string}
131  */
132 Runner.classes = {
133   CANVAS: 'runner-canvas',
134   CONTAINER: 'runner-container',
135   CRASHED: 'crashed',
136   ICON: 'icon-offline',
137   TOUCH_CONTROLLER: 'controller'
138 };
139
140
141 /**
142  * Image source urls.
143  * @enum {array.<object>}
144  */
145 Runner.imageSources = {
146   LDPI: [
147     {name: 'CACTUS_LARGE', id: '1x-obstacle-large'},
148     {name: 'CACTUS_SMALL', id: '1x-obstacle-small'},
149     {name: 'CLOUD', id: '1x-cloud'},
150     {name: 'HORIZON', id: '1x-horizon'},
151     {name: 'RESTART', id: '1x-restart'},
152     {name: 'TEXT_SPRITE', id: '1x-text'},
153     {name: 'TREX', id: '1x-trex'}
154   ],
155   HDPI: [
156     {name: 'CACTUS_LARGE', id: '2x-obstacle-large'},
157     {name: 'CACTUS_SMALL', id: '2x-obstacle-small'},
158     {name: 'CLOUD', id: '2x-cloud'},
159     {name: 'HORIZON', id: '2x-horizon'},
160     {name: 'RESTART', id: '2x-restart'},
161     {name: 'TEXT_SPRITE', id: '2x-text'},
162     {name: 'TREX', id: '2x-trex'}
163   ]
164 };
165
166
167 /**
168  * Sound FX. Reference to the ID of the audio tag on interstitial page.
169  * @enum {string}
170  */
171 Runner.sounds = {
172   BUTTON_PRESS: 'offline-sound-press',
173   HIT: 'offline-sound-hit',
174   SCORE: 'offline-sound-reached'
175 };
176
177
178 /**
179  * Key code mapping.
180  * @enum {object}
181  */
182 Runner.keycodes = {
183   JUMP: {'38': 1, '32': 1},  // Up, spacebar
184   DUCK: {'40': 1},  // Down
185   RESTART: {'13': 1}  // Enter
186 };
187
188
189 /**
190  * Runner event names.
191  * @enum {string}
192  */
193 Runner.events = {
194   ANIM_END: 'webkitAnimationEnd',
195   CLICK: 'click',
196   KEYDOWN: 'keydown',
197   KEYUP: 'keyup',
198   MOUSEDOWN: 'mousedown',
199   MOUSEUP: 'mouseup',
200   RESIZE: 'resize',
201   TOUCHEND: 'touchend',
202   TOUCHSTART: 'touchstart',
203   VISIBILITY: 'visibilitychange',
204   BLUR: 'blur',
205   FOCUS: 'focus',
206   LOAD: 'load'
207 };
208
209
210 Runner.prototype = {
211   /**
212    * Setting individual settings for debugging.
213    * @param {string} setting
214    * @param {*} value
215    */
216   updateConfigSetting: function(setting, value) {
217     if (setting in this.config && value != undefined) {
218       this.config[setting] = value;
219
220       switch (setting) {
221         case 'GRAVITY':
222         case 'MIN_JUMP_HEIGHT':
223         case 'SPEED_DROP_COEFFICIENT':
224           this.tRex.config[setting] = value;
225           break;
226         case 'INITIAL_JUMP_VELOCITY':
227           this.tRex.setJumpVelocity(value);
228           break;
229         case 'SPEED':
230           this.setSpeed(value);
231           break;
232       }
233     }
234   },
235
236   /**
237    * Load and cache the image assets from the page.
238    */
239   loadImages: function() {
240     var imageSources = IS_HIDPI ? Runner.imageSources.HDPI :
241         Runner.imageSources.LDPI;
242
243     var numImages = imageSources.length;
244
245     for (var i = numImages - 1; i >= 0; i--) {
246       var imgSource = imageSources[i];
247       this.images[imgSource.name] = document.getElementById(imgSource.id);
248     }
249     this.init();
250   },
251
252   /**
253    * Load and decode base 64 encoded sounds.
254    */
255   loadSounds: function() {
256     if (!IS_IOS) {
257       this.audioContext = new AudioContext();
258       var resourceTemplate =
259           document.getElementById(this.config.RESOURCE_TEMPLATE_ID).content;
260
261       for (var sound in Runner.sounds) {
262         var soundSrc =
263             resourceTemplate.getElementById(Runner.sounds[sound]).src;
264         soundSrc = soundSrc.substr(soundSrc.indexOf(',') + 1);
265         var buffer = decodeBase64ToArrayBuffer(soundSrc);
266
267         // Async, so no guarantee of order in array.
268         this.audioContext.decodeAudioData(buffer, function(index, audioData) {
269             this.soundFx[index] = audioData;
270           }.bind(this, sound));
271       }
272     }
273   },
274
275   /**
276    * Sets the game speed. Adjust the speed accordingly if on a smaller screen.
277    * @param {number} opt_speed
278    */
279   setSpeed: function(opt_speed) {
280     var speed = opt_speed || this.currentSpeed;
281
282     // Reduce the speed on smaller mobile screens.
283     if (this.dimensions.WIDTH < DEFAULT_WIDTH) {
284       var mobileSpeed = speed * this.dimensions.WIDTH / DEFAULT_WIDTH *
285           this.config.MOBILE_SPEED_COEFFICIENT;
286       this.currentSpeed = mobileSpeed > speed ? speed : mobileSpeed;
287     } else if (opt_speed) {
288       this.currentSpeed = opt_speed;
289     }
290   },
291
292   /**
293    * Game initialiser.
294    */
295   init: function() {
296     // Hide the static icon.
297     document.querySelector('.' + Runner.classes.ICON).style.visibility =
298         'hidden';
299
300     this.adjustDimensions();
301     this.setSpeed();
302
303     this.containerEl = document.createElement('div');
304     this.containerEl.className = Runner.classes.CONTAINER;
305
306     // Player canvas container.
307     this.canvas = createCanvas(this.containerEl, this.dimensions.WIDTH,
308         this.dimensions.HEIGHT, Runner.classes.PLAYER);
309
310     this.canvasCtx = this.canvas.getContext('2d');
311     this.canvasCtx.fillStyle = '#f7f7f7';
312     this.canvasCtx.fill();
313     Runner.updateCanvasScaling(this.canvas);
314
315     // Horizon contains clouds, obstacles and the ground.
316     this.horizon = new Horizon(this.canvas, this.images, this.dimensions,
317         this.config.GAP_COEFFICIENT);
318
319     // Distance meter
320     this.distanceMeter = new DistanceMeter(this.canvas,
321           this.images.TEXT_SPRITE, this.dimensions.WIDTH);
322
323     // Draw t-rex
324     this.tRex = new Trex(this.canvas, this.images.TREX);
325
326     this.outerContainerEl.appendChild(this.containerEl);
327
328     if (IS_MOBILE) {
329       this.createTouchController();
330     }
331
332     this.startListening();
333     this.update();
334
335     window.addEventListener(Runner.events.RESIZE,
336         this.debounceResize.bind(this));
337   },
338
339   /**
340    * Create the touch controller. A div that covers whole screen.
341    */
342   createTouchController: function() {
343     this.touchController = document.createElement('div');
344     this.touchController.className = Runner.classes.TOUCH_CONTROLLER;
345   },
346
347   /**
348    * Debounce the resize event.
349    */
350   debounceResize: function() {
351     if (!this.resizeTimerId_) {
352       this.resizeTimerId_ =
353           setInterval(this.adjustDimensions.bind(this), 250);
354     }
355   },
356
357   /**
358    * Adjust game space dimensions on resize.
359    */
360   adjustDimensions: function() {
361     clearInterval(this.resizeTimerId_);
362     this.resizeTimerId_ = null;
363
364     var boxStyles = window.getComputedStyle(this.outerContainerEl);
365     var padding = Number(boxStyles.paddingLeft.substr(0,
366         boxStyles.paddingLeft.length - 2));
367
368     this.dimensions.WIDTH = this.outerContainerEl.offsetWidth - padding * 2;
369
370     // Redraw the elements back onto the canvas.
371     if (this.canvas) {
372       this.canvas.width = this.dimensions.WIDTH;
373       this.canvas.height = this.dimensions.HEIGHT;
374
375       Runner.updateCanvasScaling(this.canvas);
376
377       this.distanceMeter.calcXPos(this.dimensions.WIDTH);
378       this.clearCanvas();
379       this.horizon.update(0, 0, true);
380       this.tRex.update(0);
381
382       // Outer container and distance meter.
383       if (this.activated || this.crashed) {
384         this.containerEl.style.width = this.dimensions.WIDTH + 'px';
385         this.containerEl.style.height = this.dimensions.HEIGHT + 'px';
386         this.distanceMeter.update(0, Math.ceil(this.distanceRan));
387         this.stop();
388       } else {
389         this.tRex.draw(0, 0);
390       }
391
392       // Game over panel.
393       if (this.crashed && this.gameOverPanel) {
394         this.gameOverPanel.updateDimensions(this.dimensions.WIDTH);
395         this.gameOverPanel.draw();
396       }
397     }
398   },
399
400   /**
401    * Play the game intro.
402    * Canvas container width expands out to the full width.
403    */
404   playIntro: function() {
405     if (!this.started && !this.crashed) {
406       this.playingIntro = true;
407       this.tRex.playingIntro = true;
408
409       // CSS animation definition.
410       var keyframes = '@-webkit-keyframes intro { ' +
411             'from { width:' + Trex.config.WIDTH + 'px }' +
412             'to { width: ' + this.dimensions.WIDTH + 'px }' +
413           '}';
414       document.styleSheets[0].insertRule(keyframes, 0);
415
416       this.containerEl.addEventListener(Runner.events.ANIM_END,
417           this.startGame.bind(this));
418
419       this.containerEl.style.webkitAnimation = 'intro .4s ease-out 1 both';
420       this.containerEl.style.width = this.dimensions.WIDTH + 'px';
421
422       if (this.touchController) {
423         this.outerContainerEl.appendChild(this.touchController);
424       }
425       this.activated = true;
426       this.started = true;
427     } else if (this.crashed) {
428       this.restart();
429     }
430   },
431
432
433   /**
434    * Update the game status to started.
435    */
436   startGame: function() {
437     this.runningTime = 0;
438     this.playingIntro = false;
439     this.tRex.playingIntro = false;
440     this.containerEl.style.webkitAnimation = '';
441     this.playCount++;
442
443     // Handle tabbing off the page. Pause the current game.
444     window.addEventListener(Runner.events.VISIBILITY,
445           this.onVisibilityChange.bind(this));
446
447     window.addEventListener(Runner.events.BLUR,
448           this.onVisibilityChange.bind(this));
449
450     window.addEventListener(Runner.events.FOCUS,
451           this.onVisibilityChange.bind(this));
452   },
453
454   clearCanvas: function() {
455     this.canvasCtx.clearRect(0, 0, this.dimensions.WIDTH,
456         this.dimensions.HEIGHT);
457   },
458
459   /**
460    * Update the game frame.
461    */
462   update: function() {
463     this.drawPending = false;
464
465     var now = getTimeStamp();
466     var deltaTime = now - (this.time || now);
467     this.time = now;
468
469     if (this.activated) {
470       this.clearCanvas();
471
472       if (this.tRex.jumping) {
473         this.tRex.updateJump(deltaTime, this.config);
474       }
475
476       this.runningTime += deltaTime;
477       var hasObstacles = this.runningTime > this.config.CLEAR_TIME;
478
479       // First jump triggers the intro.
480       if (this.tRex.jumpCount == 1 && !this.playingIntro) {
481         this.playIntro();
482       }
483
484       // The horizon doesn't move until the intro is over.
485       if (this.playingIntro) {
486         this.horizon.update(0, this.currentSpeed, hasObstacles);
487       } else {
488         deltaTime = !this.started ? 0 : deltaTime;
489         this.horizon.update(deltaTime, this.currentSpeed, hasObstacles);
490       }
491
492       // Check for collisions.
493       var collision = hasObstacles &&
494           checkForCollision(this.horizon.obstacles[0], this.tRex);
495
496       if (!collision) {
497         this.distanceRan += this.currentSpeed * deltaTime / this.msPerFrame;
498
499         if (this.currentSpeed < this.config.MAX_SPEED) {
500           this.currentSpeed += this.config.ACCELERATION;
501         }
502       } else {
503         this.gameOver();
504       }
505
506       if (this.distanceMeter.getActualDistance(this.distanceRan) >
507           this.distanceMeter.maxScore) {
508         this.distanceRan = 0;
509       }
510
511       var playAcheivementSound = this.distanceMeter.update(deltaTime,
512           Math.ceil(this.distanceRan));
513
514       if (playAcheivementSound) {
515         this.playSound(this.soundFx.SCORE);
516       }
517     }
518
519     if (!this.crashed) {
520       this.tRex.update(deltaTime);
521       this.raq();
522     }
523   },
524
525   /**
526    * Event handler.
527    */
528   handleEvent: function(e) {
529     return (function(evtType, events) {
530       switch (evtType) {
531         case events.KEYDOWN:
532         case events.TOUCHSTART:
533         case events.MOUSEDOWN:
534           this.onKeyDown(e);
535           break;
536         case events.KEYUP:
537         case events.TOUCHEND:
538         case events.MOUSEUP:
539           this.onKeyUp(e);
540           break;
541       }
542     }.bind(this))(e.type, Runner.events);
543   },
544
545   /**
546    * Bind relevant key / mouse / touch listeners.
547    */
548   startListening: function() {
549     // Keys.
550     document.addEventListener(Runner.events.KEYDOWN, this);
551     document.addEventListener(Runner.events.KEYUP, this);
552
553     if (IS_MOBILE) {
554       // Mobile only touch devices.
555       this.touchController.addEventListener(Runner.events.TOUCHSTART, this);
556       this.touchController.addEventListener(Runner.events.TOUCHEND, this);
557       this.containerEl.addEventListener(Runner.events.TOUCHSTART, this);
558     } else {
559       // Mouse.
560       document.addEventListener(Runner.events.MOUSEDOWN, this);
561       document.addEventListener(Runner.events.MOUSEUP, this);
562     }
563   },
564
565   /**
566    * Remove all listeners.
567    */
568   stopListening: function() {
569     document.removeEventListener(Runner.events.KEYDOWN, this);
570     document.removeEventListener(Runner.events.KEYUP, this);
571
572     if (IS_MOBILE) {
573       this.touchController.removeEventListener(Runner.events.TOUCHSTART, this);
574       this.touchController.removeEventListener(Runner.events.TOUCHEND, this);
575       this.containerEl.removeEventListener(Runner.events.TOUCHSTART, this);
576     } else {
577       document.removeEventListener(Runner.events.MOUSEDOWN, this);
578       document.removeEventListener(Runner.events.MOUSEUP, this);
579     }
580   },
581
582   /**
583    * Process keydown.
584    * @param {Event} e
585    */
586   onKeyDown: function(e) {
587     if (e.target != this.detailsButton) {
588       if (!this.crashed && (Runner.keycodes.JUMP[String(e.keyCode)] ||
589            e.type == Runner.events.TOUCHSTART)) {
590         if (!this.activated) {
591           this.loadSounds();
592           this.activated = true;
593         }
594
595         if (!this.tRex.jumping) {
596           this.playSound(this.soundFx.BUTTON_PRESS);
597           this.tRex.startJump();
598         }
599       }
600
601       if (this.crashed && e.type == Runner.events.TOUCHSTART &&
602           e.currentTarget == this.containerEl) {
603         this.restart();
604       }
605     }
606
607     // Speed drop, activated only when jump key is not pressed.
608     if (Runner.keycodes.DUCK[e.keyCode] && this.tRex.jumping) {
609       e.preventDefault();
610       this.tRex.setSpeedDrop();
611     }
612   },
613
614
615   /**
616    * Process key up.
617    * @param {Event} e
618    */
619   onKeyUp: function(e) {
620     var keyCode = String(e.keyCode);
621     var isjumpKey = Runner.keycodes.JUMP[keyCode] ||
622        e.type == Runner.events.TOUCHEND ||
623        e.type == Runner.events.MOUSEDOWN;
624
625     if (this.isRunning() && isjumpKey) {
626       this.tRex.endJump();
627     } else if (Runner.keycodes.DUCK[keyCode]) {
628       this.tRex.speedDrop = false;
629     } else if (this.crashed) {
630       // Check that enough time has elapsed before allowing jump key to restart.
631       var deltaTime = getTimeStamp() - this.time;
632
633       if (Runner.keycodes.RESTART[keyCode] ||
634          (e.type == Runner.events.MOUSEUP && e.target == this.canvas) ||
635          (deltaTime >= this.config.GAMEOVER_CLEAR_TIME &&
636          Runner.keycodes.JUMP[keyCode])) {
637         this.restart();
638       }
639     } else if (this.paused && isjumpKey) {
640       this.play();
641     }
642   },
643
644   /**
645    * RequestAnimationFrame wrapper.
646    */
647   raq: function() {
648     if (!this.drawPending) {
649       this.drawPending = true;
650       this.raqId = requestAnimationFrame(this.update.bind(this));
651     }
652   },
653
654   /**
655    * Whether the game is running.
656    * @return {boolean}
657    */
658   isRunning: function() {
659     return !!this.raqId;
660   },
661
662   /**
663    * Game over state.
664    */
665   gameOver: function() {
666     this.playSound(this.soundFx.HIT);
667     vibrate(200);
668
669     this.stop();
670     this.crashed = true;
671     this.distanceMeter.acheivement = false;
672
673     this.tRex.update(100, Trex.status.CRASHED);
674
675     // Game over panel.
676     if (!this.gameOverPanel) {
677       this.gameOverPanel = new GameOverPanel(this.canvas,
678           this.images.TEXT_SPRITE, this.images.RESTART,
679           this.dimensions);
680     } else {
681       this.gameOverPanel.draw();
682     }
683
684     // Update the high score.
685     if (this.distanceRan > this.highestScore) {
686       this.highestScore = Math.ceil(this.distanceRan);
687       this.distanceMeter.setHighScore(this.highestScore);
688     }
689
690     // Reset the time clock.
691     this.time = getTimeStamp();
692   },
693
694   stop: function() {
695     this.activated = false;
696     this.paused = true;
697     cancelAnimationFrame(this.raqId);
698     this.raqId = 0;
699   },
700
701   play: function() {
702     if (!this.crashed) {
703       this.activated = true;
704       this.paused = false;
705       this.tRex.update(0, Trex.status.RUNNING);
706       this.time = getTimeStamp();
707       this.update();
708     }
709   },
710
711   restart: function() {
712     if (!this.raqId) {
713       this.playCount++;
714       this.runningTime = 0;
715       this.activated = true;
716       this.crashed = false;
717       this.distanceRan = 0;
718       this.setSpeed(this.config.SPEED);
719
720       this.time = getTimeStamp();
721       this.containerEl.classList.remove(Runner.classes.CRASHED);
722       this.clearCanvas();
723       this.distanceMeter.reset(this.highestScore);
724       this.horizon.reset();
725       this.tRex.reset();
726       this.playSound(this.soundFx.BUTTON_PRESS);
727
728       this.update();
729     }
730   },
731
732   /**
733    * Pause the game if the tab is not in focus.
734    */
735   onVisibilityChange: function(e) {
736     if (document.hidden || document.webkitHidden || e.type == 'blur') {
737       this.stop();
738     } else {
739       this.play();
740     }
741   },
742
743   /**
744    * Play a sound.
745    * @param {SoundBuffer} soundBuffer
746    */
747   playSound: function(soundBuffer) {
748     if (soundBuffer) {
749       var sourceNode = this.audioContext.createBufferSource();
750       sourceNode.buffer = soundBuffer;
751       sourceNode.connect(this.audioContext.destination);
752       sourceNode.start(0);
753     }
754   }
755 };
756
757
758 /**
759  * Updates the canvas size taking into
760  * account the backing store pixel ratio and
761  * the device pixel ratio.
762  *
763  * See article by Paul Lewis:
764  * http://www.html5rocks.com/en/tutorials/canvas/hidpi/
765  *
766  * @param {HTMLCanvasElement} canvas
767  * @param {number} opt_width
768  * @param {number} opt_height
769  * @return {boolean} Whether the canvas was scaled.
770  */
771 Runner.updateCanvasScaling = function(canvas, opt_width, opt_height) {
772   var context = canvas.getContext('2d');
773
774   // Query the various pixel ratios
775   var devicePixelRatio = Math.floor(window.devicePixelRatio) || 1;
776   var backingStoreRatio = Math.floor(context.webkitBackingStorePixelRatio) || 1;
777   var ratio = devicePixelRatio / backingStoreRatio;
778
779   // Upscale the canvas if the two ratios don't match
780   if (devicePixelRatio !== backingStoreRatio) {
781
782     var oldWidth = opt_width || canvas.width;
783     var oldHeight = opt_height || canvas.height;
784
785     canvas.width = oldWidth * ratio;
786     canvas.height = oldHeight * ratio;
787
788     canvas.style.width = oldWidth + 'px';
789     canvas.style.height = oldHeight + 'px';
790
791     // Scale the context to counter the fact that we've manually scaled
792     // our canvas element.
793     context.scale(ratio, ratio);
794     return true;
795   }
796   return false;
797 };
798
799
800 /**
801  * Get random number.
802  * @param {number} min
803  * @param {number} max
804  * @param {number}
805  */
806 function getRandomNum(min, max) {
807   return Math.floor(Math.random() * (max - min + 1)) + min;
808 }
809
810
811 /**
812  * Vibrate on mobile devices.
813  * @param {number} duration Duration of the vibration in milliseconds.
814  */
815 function vibrate(duration) {
816   if (IS_MOBILE && window.navigator.vibrate) {
817     window.navigator.vibrate(duration);
818   }
819 }
820
821
822 /**
823  * Create canvas element.
824  * @param {HTMLElement} container Element to append canvas to.
825  * @param {number} width
826  * @param {number} height
827  * @param {string} opt_classname
828  * @return {HTMLCanvasElement}
829  */
830 function createCanvas(container, width, height, opt_classname) {
831   var canvas = document.createElement('canvas');
832   canvas.className = opt_classname ? Runner.classes.CANVAS + ' ' +
833       opt_classname : Runner.classes.CANVAS;
834   canvas.width = width;
835   canvas.height = height;
836   container.appendChild(canvas);
837
838   return canvas;
839 }
840
841
842 /**
843  * Decodes the base 64 audio to ArrayBuffer used by Web Audio.
844  * @param {string} base64String
845  */
846 function decodeBase64ToArrayBuffer(base64String) {
847   var len = (base64String.length / 4) * 3;
848   var str = atob(base64String);
849   var arrayBuffer = new ArrayBuffer(len);
850   var bytes = new Uint8Array(arrayBuffer);
851
852   for (var i = 0; i < len; i++) {
853     bytes[i] = str.charCodeAt(i);
854   }
855   return bytes.buffer;
856 }
857
858
859 /**
860  * Return the current timestamp.
861  * @return {number}
862  */
863 function getTimeStamp() {
864   return IS_IOS ? new Date().getTime() : performance.now();
865 }
866
867
868 //******************************************************************************
869
870
871 /**
872  * Game over panel.
873  * @param {!HTMLCanvasElement} canvas
874  * @param {!HTMLImage} textSprite
875  * @param {!HTMLImage} restartImg
876  * @param {!Object} dimensions Canvas dimensions.
877  * @constructor
878  */
879 function GameOverPanel(canvas, textSprite, restartImg, dimensions) {
880   this.canvas = canvas;
881   this.canvasCtx = canvas.getContext('2d');
882   this.canvasDimensions = dimensions;
883   this.textSprite = textSprite;
884   this.restartImg = restartImg;
885   this.draw();
886 };
887
888
889 /**
890  * Dimensions used in the panel.
891  * @enum {number}
892  */
893 GameOverPanel.dimensions = {
894   TEXT_X: 0,
895   TEXT_Y: 13,
896   TEXT_WIDTH: 191,
897   TEXT_HEIGHT: 11,
898   RESTART_WIDTH: 36,
899   RESTART_HEIGHT: 32
900 };
901
902
903 GameOverPanel.prototype = {
904   /**
905    * Update the panel dimensions.
906    * @param {number} width New canvas width.
907    * @param {number} opt_height Optional new canvas height.
908    */
909   updateDimensions: function(width, opt_height) {
910     this.canvasDimensions.WIDTH = width;
911     if (opt_height) {
912       this.canvasDimensions.HEIGHT = opt_height;
913     }
914   },
915
916   /**
917    * Draw the panel.
918    */
919   draw: function() {
920     var dimensions = GameOverPanel.dimensions;
921
922     var centerX = this.canvasDimensions.WIDTH / 2;
923
924     // Game over text.
925     var textSourceX = dimensions.TEXT_X;
926     var textSourceY = dimensions.TEXT_Y;
927     var textSourceWidth = dimensions.TEXT_WIDTH;
928     var textSourceHeight = dimensions.TEXT_HEIGHT;
929
930     var textTargetX = Math.round(centerX - (dimensions.TEXT_WIDTH / 2));
931     var textTargetY = Math.round((this.canvasDimensions.HEIGHT - 25) / 3);
932     var textTargetWidth = dimensions.TEXT_WIDTH;
933     var textTargetHeight = dimensions.TEXT_HEIGHT;
934
935     var restartSourceWidth = dimensions.RESTART_WIDTH;
936     var restartSourceHeight = dimensions.RESTART_HEIGHT;
937     var restartTargetX = centerX - (dimensions.RESTART_WIDTH / 2);
938     var restartTargetY = this.canvasDimensions.HEIGHT / 2;
939
940     if (IS_HIDPI) {
941       textSourceY *= 2;
942       textSourceX *= 2;
943       textSourceWidth *= 2;
944       textSourceHeight *= 2;
945       restartSourceWidth *= 2;
946       restartSourceHeight *= 2;
947     }
948
949     // Game over text from sprite.
950     this.canvasCtx.drawImage(this.textSprite,
951         textSourceX, textSourceY, textSourceWidth, textSourceHeight,
952         textTargetX, textTargetY, textTargetWidth, textTargetHeight);
953
954     // Restart button.
955     this.canvasCtx.drawImage(this.restartImg, 0, 0,
956         restartSourceWidth, restartSourceHeight,
957         restartTargetX, restartTargetY, dimensions.RESTART_WIDTH,
958         dimensions.RESTART_HEIGHT);
959   }
960 };
961
962
963 //******************************************************************************
964
965 /**
966  * Check for a collision.
967  * @param {!Obstacle} obstacle
968  * @param {!Trex} tRex T-rex object.
969  * @param {HTMLCanvasContext} opt_canvasCtx Optional canvas context for drawing
970  *    collision boxes.
971  * @return {Array.<CollisionBox>}
972  */
973 function checkForCollision(obstacle, tRex, opt_canvasCtx) {
974   var obstacleBoxXPos = Runner.defaultDimensions.WIDTH + obstacle.xPos;
975
976   // Adjustments are made to the bounding box as there is a 1 pixel white
977   // border around the t-rex and obstacles.
978   var tRexBox = new CollisionBox(
979       tRex.xPos + 1,
980       tRex.yPos + 1,
981       tRex.config.WIDTH - 2,
982       tRex.config.HEIGHT - 2);
983
984   var obstacleBox = new CollisionBox(
985       obstacle.xPos + 1,
986       obstacle.yPos + 1,
987       obstacle.typeConfig.width * obstacle.size - 2,
988       obstacle.typeConfig.height - 2);
989
990   // Debug outer box
991   if (opt_canvasCtx) {
992     drawCollisionBoxes(opt_canvasCtx, tRexBox, obstacleBox);
993   }
994
995   // Simple outer bounds check.
996   if (boxCompare(tRexBox, obstacleBox)) {
997     var collisionBoxes = obstacle.collisionBoxes;
998     var tRexCollisionBoxes = Trex.collisionBoxes;
999
1000     // Detailed axis aligned box check.
1001     for (var t = 0; t < tRexCollisionBoxes.length; t++) {
1002       for (var i = 0; i < collisionBoxes.length; i++) {
1003         // Adjust the box to actual positions.
1004         var adjTrexBox =
1005             createAdjustedCollisionBox(tRexCollisionBoxes[t], tRexBox);
1006         var adjObstacleBox =
1007             createAdjustedCollisionBox(collisionBoxes[i], obstacleBox);
1008         var crashed = boxCompare(adjTrexBox, adjObstacleBox);
1009
1010         // Draw boxes for debug.
1011         if (opt_canvasCtx) {
1012           drawCollisionBoxes(opt_canvasCtx, adjTrexBox, adjObstacleBox);
1013         }
1014
1015         if (crashed) {
1016           return [adjTrexBox, adjObstacleBox];
1017         }
1018       }
1019     }
1020   }
1021   return false;
1022 };
1023
1024
1025 /**
1026  * Adjust the collision box.
1027  * @param {!CollisionBox} box The original box.
1028  * @param {!CollisionBox} adjustment Adjustment box.
1029  * @return {CollisionBox} The adjusted collision box object.
1030  */
1031 function createAdjustedCollisionBox(box, adjustment) {
1032   return new CollisionBox(
1033       box.x + adjustment.x,
1034       box.y + adjustment.y,
1035       box.width,
1036       box.height);
1037 };
1038
1039
1040 /**
1041  * Draw the collision boxes for debug.
1042  */
1043 function drawCollisionBoxes(canvasCtx, tRexBox, obstacleBox) {
1044   canvasCtx.save();
1045   canvasCtx.strokeStyle = '#f00';
1046   canvasCtx.strokeRect(tRexBox.x, tRexBox.y,
1047   tRexBox.width, tRexBox.height);
1048
1049   canvasCtx.strokeStyle = '#0f0';
1050   canvasCtx.strokeRect(obstacleBox.x, obstacleBox.y,
1051   obstacleBox.width, obstacleBox.height);
1052   canvasCtx.restore();
1053 };
1054
1055
1056 /**
1057  * Compare two collision boxes for a collision.
1058  * @param {CollisionBox} tRexBox
1059  * @param {CollisionBox} obstacleBox
1060  * @return {boolean} Whether the boxes intersected.
1061  */
1062 function boxCompare(tRexBox, obstacleBox) {
1063   var crashed = false;
1064   var tRexBoxX = tRexBox.x;
1065   var tRexBoxY = tRexBox.y;
1066
1067   var obstacleBoxX = obstacleBox.x;
1068   var obstacleBoxY = obstacleBox.y;
1069
1070   // Axis-Aligned Bounding Box method.
1071   if (tRexBox.x < obstacleBoxX + obstacleBox.width &&
1072       tRexBox.x + tRexBox.width > obstacleBoxX &&
1073       tRexBox.y < obstacleBox.y + obstacleBox.height &&
1074       tRexBox.height + tRexBox.y > obstacleBox.y) {
1075     crashed = true;
1076   }
1077
1078   return crashed;
1079 };
1080
1081
1082 //******************************************************************************
1083
1084 /**
1085  * Collision box object.
1086  * @param {number} x X position.
1087  * @param {number} y Y Position.
1088  * @param {number} w Width.
1089  * @param {number} h Height.
1090  */
1091 function CollisionBox(x, y, w, h) {
1092   this.x = x;
1093   this.y = y;
1094   this.width = w;
1095   this.height = h;
1096 };
1097
1098
1099 //******************************************************************************
1100
1101 /**
1102  * Obstacle.
1103  * @param {HTMLCanvasCtx} canvasCtx
1104  * @param {Obstacle.type} type
1105  * @param {image} obstacleImg Image sprite.
1106  * @param {Object} dimensions
1107  * @param {number} gapCoefficient Mutipler in determining the gap.
1108  * @param {number} speed
1109  */
1110 function Obstacle(canvasCtx, type, obstacleImg, dimensions,
1111     gapCoefficient, speed) {
1112
1113   this.canvasCtx = canvasCtx;
1114   this.image = obstacleImg;
1115   this.typeConfig = type;
1116   this.gapCoefficient = gapCoefficient;
1117   this.size = getRandomNum(1, Obstacle.MAX_OBSTACLE_LENGTH);
1118   this.dimensions = dimensions;
1119   this.remove = false;
1120   this.xPos = 0;
1121   this.yPos = this.typeConfig.yPos;
1122   this.width = 0;
1123   this.collisionBoxes = [];
1124   this.gap = 0;
1125
1126   this.init(speed);
1127 };
1128
1129 /**
1130  * Coefficient for calculating the maximum gap.
1131  * @const
1132  */
1133 Obstacle.MAX_GAP_COEFFICIENT = 1.5;
1134
1135 /**
1136  * Maximum obstacle grouping count.
1137  * @const
1138  */
1139 Obstacle.MAX_OBSTACLE_LENGTH = 3,
1140
1141
1142 Obstacle.prototype = {
1143   /**
1144    * Initialise the DOM for the obstacle.
1145    * @param {number} speed
1146    */
1147   init: function(speed) {
1148     this.cloneCollisionBoxes();
1149
1150     // Only allow sizing if we're at the right speed.
1151     if (this.size > 1 && this.typeConfig.multipleSpeed > speed) {
1152       this.size = 1;
1153     }
1154
1155     this.width = this.typeConfig.width * this.size;
1156     this.xPos = this.dimensions.WIDTH - this.width;
1157
1158     this.draw();
1159
1160     // Make collision box adjustments,
1161     // Central box is adjusted to the size as one box.
1162     //      ____        ______        ________
1163     //    _|   |-|    _|     |-|    _|       |-|
1164     //   | |<->| |   | |<--->| |   | |<----->| |
1165     //   | | 1 | |   | |  2  | |   | |   3   | |
1166     //   |_|___|_|   |_|_____|_|   |_|_______|_|
1167     //
1168     if (this.size > 1) {
1169       this.collisionBoxes[1].width = this.width - this.collisionBoxes[0].width -
1170           this.collisionBoxes[2].width;
1171       this.collisionBoxes[2].x = this.width - this.collisionBoxes[2].width;
1172     }
1173
1174     this.gap = this.getGap(this.gapCoefficient, speed);
1175   },
1176
1177   /**
1178    * Draw and crop based on size.
1179    */
1180   draw: function() {
1181     var sourceWidth = this.typeConfig.width;
1182     var sourceHeight = this.typeConfig.height;
1183
1184     if (IS_HIDPI) {
1185       sourceWidth = sourceWidth * 2;
1186       sourceHeight = sourceHeight * 2;
1187     }
1188
1189     // Sprite
1190     var sourceX = (sourceWidth * this.size) * (0.5 * (this.size - 1));
1191     this.canvasCtx.drawImage(this.image,
1192       sourceX, 0,
1193       sourceWidth * this.size, sourceHeight,
1194       this.xPos, this.yPos,
1195       this.typeConfig.width * this.size, this.typeConfig.height);
1196   },
1197
1198   /**
1199    * Obstacle frame update.
1200    * @param {number} deltaTime
1201    * @param {number} speed
1202    */
1203   update: function(deltaTime, speed) {
1204     if (!this.remove) {
1205       this.xPos -= Math.floor((speed * FPS / 1000) * deltaTime);
1206       this.draw();
1207
1208       if (!this.isVisible()) {
1209         this.remove = true;
1210       }
1211     }
1212   },
1213
1214   /**
1215    * Calculate a random gap size.
1216    * - Minimum gap gets wider as speed increses
1217    * @param {number} gapCoefficient
1218    * @param {number} speed
1219    * @return {number} The gap size.
1220    */
1221   getGap: function(gapCoefficient, speed) {
1222     var minGap = Math.round(this.width * speed +
1223           this.typeConfig.minGap * gapCoefficient);
1224     var maxGap = Math.round(minGap * Obstacle.MAX_GAP_COEFFICIENT);
1225     return getRandomNum(minGap, maxGap);
1226   },
1227
1228   /**
1229    * Check if obstacle is visible.
1230    * @return {boolean} Whether the obstacle is in the game area.
1231    */
1232   isVisible: function() {
1233     return this.xPos + this.width > 0;
1234   },
1235
1236   /**
1237    * Make a copy of the collision boxes, since these will change based on
1238    * obstacle type and size.
1239    */
1240   cloneCollisionBoxes: function() {
1241     var collisionBoxes = this.typeConfig.collisionBoxes;
1242
1243     for (var i = collisionBoxes.length - 1; i >= 0; i--) {
1244       this.collisionBoxes[i] = new CollisionBox(collisionBoxes[i].x,
1245           collisionBoxes[i].y, collisionBoxes[i].width,
1246           collisionBoxes[i].height);
1247     }
1248   }
1249 };
1250
1251
1252 /**
1253  * Obstacle definitions.
1254  * minGap: minimum pixel space betweeen obstacles.
1255  * multipleSpeed: Speed at which multiples are allowed.
1256  */
1257 Obstacle.types = [
1258   {
1259     type: 'CACTUS_SMALL',
1260     className: ' cactus cactus-small ',
1261     width: 17,
1262     height: 35,
1263     yPos: 105,
1264     multipleSpeed: 3,
1265     minGap: 120,
1266     collisionBoxes: [
1267       new CollisionBox(0, 7, 5, 27),
1268       new CollisionBox(4, 0, 6, 34),
1269       new CollisionBox(10, 4, 7, 14)
1270     ]
1271   },
1272   {
1273     type: 'CACTUS_LARGE',
1274     className: ' cactus cactus-large ',
1275     width: 25,
1276     height: 50,
1277     yPos: 90,
1278     multipleSpeed: 6,
1279     minGap: 120,
1280     collisionBoxes: [
1281       new CollisionBox(0, 12, 7, 38),
1282       new CollisionBox(8, 0, 7, 49),
1283       new CollisionBox(13, 10, 10, 38)
1284     ]
1285   }
1286 ];
1287
1288
1289 //******************************************************************************
1290 /**
1291  * T-rex game character.
1292  * @param {HTMLCanvas} canvas
1293  * @param {HTMLImage} image Character image.
1294  * @constructor
1295  */
1296 function Trex(canvas, image) {
1297   this.canvas = canvas;
1298   this.canvasCtx = canvas.getContext('2d');
1299   this.image = image;
1300   this.xPos = 0;
1301   this.yPos = 0;
1302   // Position when on the ground.
1303   this.groundYPos = 0;
1304   this.currentFrame = 0;
1305   this.currentAnimFrames = [];
1306   this.blinkDelay = 0;
1307   this.animStartTime = 0;
1308   this.timer = 0;
1309   this.msPerFrame = 1000 / FPS;
1310   this.config = Trex.config;
1311   // Current status.
1312   this.status = Trex.status.WAITING;
1313
1314   this.jumping = false;
1315   this.jumpVelocity = 0;
1316   this.reachedMinHeight = false;
1317   this.speedDrop = false;
1318   this.jumpCount = 0;
1319   this.jumpspotX = 0;
1320
1321   this.init();
1322 };
1323
1324
1325 /**
1326  * T-rex player config.
1327  * @enum {number}
1328  */
1329 Trex.config = {
1330   DROP_VELOCITY: -5,
1331   GRAVITY: 0.6,
1332   HEIGHT: 47,
1333   INIITAL_JUMP_VELOCITY: -10,
1334   INTRO_DURATION: 1500,
1335   MAX_JUMP_HEIGHT: 30,
1336   MIN_JUMP_HEIGHT: 30,
1337   SPEED_DROP_COEFFICIENT: 3,
1338   SPRITE_WIDTH: 262,
1339   START_X_POS: 50,
1340   WIDTH: 44
1341 };
1342
1343
1344 /**
1345  * Used in collision detection.
1346  * @type {Array.<CollisionBox>}
1347  */
1348 Trex.collisionBoxes = [
1349   new CollisionBox(1, -1, 30, 26),
1350   new CollisionBox(32, 0, 8, 16),
1351   new CollisionBox(10, 35, 14, 8),
1352   new CollisionBox(1, 24, 29, 5),
1353   new CollisionBox(5, 30, 21, 4),
1354   new CollisionBox(9, 34, 15, 4)
1355 ];
1356
1357
1358 /**
1359  * Animation states.
1360  * @enum {string}
1361  */
1362 Trex.status = {
1363   CRASHED: 'CRASHED',
1364   JUMPING: 'JUMPING',
1365   RUNNING: 'RUNNING',
1366   WAITING: 'WAITING'
1367 };
1368
1369 /**
1370  * Blinking coefficient.
1371  * @const
1372  */
1373 Trex.BLINK_TIMING = 7000;
1374
1375
1376 /**
1377  * Animation config for different states.
1378  * @enum {object}
1379  */
1380 Trex.animFrames = {
1381   WAITING: {
1382     frames: [44, 0],
1383     msPerFrame: 1000 / 3
1384   },
1385   RUNNING: {
1386     frames: [88, 132],
1387     msPerFrame: 1000 / 12
1388   },
1389   CRASHED: {
1390     frames: [220],
1391     msPerFrame: 1000 / 60
1392   },
1393   JUMPING: {
1394     frames: [0],
1395     msPerFrame: 1000 / 60
1396   }
1397 };
1398
1399
1400 Trex.prototype = {
1401   /**
1402    * T-rex player initaliser.
1403    * Sets the t-rex to blink at random intervals.
1404    */
1405   init: function() {
1406     this.blinkDelay = this.setBlinkDelay();
1407     this.groundYPos = Runner.defaultDimensions.HEIGHT - this.config.HEIGHT -
1408         Runner.config.BOTTOM_PAD;
1409     this.yPos = this.groundYPos;
1410     this.minJumpHeight = this.groundYPos - this.config.MIN_JUMP_HEIGHT;
1411
1412     this.draw(0, 0);
1413     this.update(0, Trex.status.WAITING);
1414   },
1415
1416   /**
1417    * Setter for the jump velocity.
1418    * The approriate drop velocity is also set.
1419    */
1420   setJumpVelocity: function(setting) {
1421     this.config.INIITAL_JUMP_VELOCITY = -setting;
1422     this.config.DROP_VELOCITY = -setting / 2;
1423   },
1424
1425   /**
1426    * Set the animation status.
1427    * @param {!number} deltaTime
1428    * @param {Trex.status} status Optional status to switch to.
1429    */
1430   update: function(deltaTime, opt_status) {
1431     this.timer += deltaTime;
1432
1433     // Update the status.
1434     if (opt_status) {
1435       this.status = opt_status;
1436       this.currentFrame = 0;
1437       this.msPerFrame = Trex.animFrames[opt_status].msPerFrame;
1438       this.currentAnimFrames = Trex.animFrames[opt_status].frames;
1439
1440       if (opt_status == Trex.status.WAITING) {
1441         this.animStartTime = getTimeStamp();
1442         this.setBlinkDelay();
1443       }
1444     }
1445
1446     // Game intro animation, T-rex moves in from the left.
1447     if (this.playingIntro && this.xPos < this.config.START_X_POS) {
1448       this.xPos += Math.round((this.config.START_X_POS /
1449           this.config.INTRO_DURATION) * deltaTime);
1450     }
1451
1452     if (this.status == Trex.status.WAITING) {
1453       this.blink(getTimeStamp());
1454     } else {
1455       this.draw(this.currentAnimFrames[this.currentFrame], 0);
1456     }
1457
1458     // Update the frame position.
1459     if (this.timer >= this.msPerFrame) {
1460       this.currentFrame = this.currentFrame ==
1461           this.currentAnimFrames.length - 1 ? 0 : this.currentFrame + 1;
1462       this.timer = 0;
1463     }
1464   },
1465
1466   /**
1467    * Draw the t-rex to a particular position.
1468    * @param {number} x
1469    * @param {number} y
1470    */
1471   draw: function(x, y) {
1472     var sourceX = x;
1473     var sourceY = y;
1474     var sourceWidth = this.config.WIDTH;
1475     var sourceHeight = this.config.HEIGHT;
1476
1477     if (IS_HIDPI) {
1478       sourceX *= 2;
1479       sourceY *= 2;
1480       sourceWidth *= 2;
1481       sourceHeight *= 2;
1482     }
1483
1484     this.canvasCtx.drawImage(this.image, sourceX, sourceY,
1485         sourceWidth, sourceHeight,
1486         this.xPos, this.yPos,
1487         this.config.WIDTH, this.config.HEIGHT);
1488   },
1489
1490   /**
1491    * Sets a random time for the blink to happen.
1492    */
1493   setBlinkDelay: function() {
1494     this.blinkDelay = Math.ceil(Math.random() * Trex.BLINK_TIMING);
1495   },
1496
1497   /**
1498    * Make t-rex blink at random intervals.
1499    * @param {number} time Current time in milliseconds.
1500    */
1501   blink: function(time) {
1502     var deltaTime = time - this.animStartTime;
1503
1504     if (deltaTime >= this.blinkDelay) {
1505       this.draw(this.currentAnimFrames[this.currentFrame], 0);
1506
1507       if (this.currentFrame == 1) {
1508         // Set new random delay to blink.
1509         this.setBlinkDelay();
1510         this.animStartTime = time;
1511       }
1512     }
1513   },
1514
1515   /**
1516    * Initialise a jump.
1517    */
1518   startJump: function() {
1519     if (!this.jumping) {
1520       this.update(0, Trex.status.JUMPING);
1521       this.jumpVelocity = this.config.INIITAL_JUMP_VELOCITY;
1522       this.jumping = true;
1523       this.reachedMinHeight = false;
1524       this.speedDrop = false;
1525     }
1526   },
1527
1528   /**
1529    * Jump is complete, falling down.
1530    */
1531   endJump: function() {
1532     if (this.reachedMinHeight &&
1533         this.jumpVelocity < this.config.DROP_VELOCITY) {
1534       this.jumpVelocity = this.config.DROP_VELOCITY;
1535     }
1536   },
1537
1538   /**
1539    * Update frame for a jump.
1540    * @param {number} deltaTime
1541    */
1542   updateJump: function(deltaTime) {
1543     var msPerFrame = Trex.animFrames[this.status].msPerFrame;
1544     var framesElapsed = deltaTime / msPerFrame;
1545
1546     // Speed drop makes Trex fall faster.
1547     if (this.speedDrop) {
1548       this.yPos += Math.round(this.jumpVelocity *
1549           this.config.SPEED_DROP_COEFFICIENT * framesElapsed);
1550     } else {
1551       this.yPos += Math.round(this.jumpVelocity * framesElapsed);
1552     }
1553
1554     this.jumpVelocity += this.config.GRAVITY * framesElapsed;
1555
1556     // Minimum height has been reached.
1557     if (this.yPos < this.minJumpHeight || this.speedDrop) {
1558       this.reachedMinHeight = true;
1559     }
1560
1561     // Reached max height
1562     if (this.yPos < this.config.MAX_JUMP_HEIGHT || this.speedDrop) {
1563       this.endJump();
1564     }
1565
1566     // Back down at ground level. Jump completed.
1567     if (this.yPos > this.groundYPos) {
1568       this.reset();
1569       this.jumpCount++;
1570     }
1571
1572     this.update(deltaTime);
1573   },
1574
1575   /**
1576    * Set the speed drop. Immediately cancels the current jump.
1577    */
1578   setSpeedDrop: function() {
1579     this.speedDrop = true;
1580     this.jumpVelocity = 1;
1581   },
1582
1583   /**
1584    * Reset the t-rex to running at start of game.
1585    */
1586   reset: function() {
1587     this.yPos = this.groundYPos;
1588     this.jumpVelocity = 0;
1589     this.jumping = false;
1590     this.update(0, Trex.status.RUNNING);
1591     this.midair = false;
1592     this.speedDrop = false;
1593     this.jumpCount = 0;
1594   }
1595 };
1596
1597
1598 //******************************************************************************
1599
1600 /**
1601  * Handles displaying the distance meter.
1602  * @param {!HTMLCanvasElement} canvas
1603  * @param {!HTMLImage} spriteSheet Image sprite.
1604  * @param {number} canvasWidth
1605  * @constructor
1606  */
1607 function DistanceMeter(canvas, spriteSheet, canvasWidth) {
1608   this.canvas = canvas;
1609   this.canvasCtx = canvas.getContext('2d');
1610   this.image = spriteSheet;
1611   this.x = 0;
1612   this.y = 5;
1613
1614   this.currentDistance = 0;
1615   this.maxScore = 0;
1616   this.highScore = 0;
1617   this.container = null;
1618
1619   this.digits = [];
1620   this.acheivement = false;
1621   this.defaultString = '';
1622   this.flashTimer = 0;
1623   this.flashIterations = 0;
1624
1625   this.config = DistanceMeter.config;
1626   this.init(canvasWidth);
1627 };
1628
1629
1630 /**
1631  * @enum {number}
1632  */
1633 DistanceMeter.dimensions = {
1634   WIDTH: 10,
1635   HEIGHT: 13,
1636   DEST_WIDTH: 11
1637 };
1638
1639
1640 /**
1641  * Y positioning of the digits in the sprite sheet.
1642  * X position is always 0.
1643  * @type {array.<number>}
1644  */
1645 DistanceMeter.yPos = [0, 13, 27, 40, 53, 67, 80, 93, 107, 120];
1646
1647
1648 /**
1649  * Distance meter config.
1650  * @enum {number}
1651  */
1652 DistanceMeter.config = {
1653   // Number of digits.
1654   MAX_DISTANCE_UNITS: 5,
1655
1656   // Distance that causes achievement animation.
1657   ACHIEVEMENT_DISTANCE: 100,
1658
1659   // Used for conversion from pixel distance to a scaled unit.
1660   COEFFICIENT: 0.025,
1661
1662   // Flash duration in milliseconds.
1663   FLASH_DURATION: 1000 / 4,
1664
1665   // Flash iterations for achievement animation.
1666   FLASH_ITERATIONS: 3
1667 };
1668
1669
1670 DistanceMeter.prototype = {
1671   /**
1672    * Initialise the distance meter to '00000'.
1673    * @param {number} width Canvas width in px.
1674    */
1675   init: function(width) {
1676     var maxDistanceStr = '';
1677
1678     this.calcXPos(width);
1679     this.maxScore = this.config.MAX_DISTANCE_UNITS;
1680     for (var i = 0; i < this.config.MAX_DISTANCE_UNITS; i++) {
1681       this.draw(i, 0);
1682       this.defaultString += '0';
1683       maxDistanceStr += '9';
1684     }
1685
1686     this.maxScore = parseInt(maxDistanceStr);
1687   },
1688
1689   /**
1690    * Calculate the xPos in the canvas.
1691    * @param {number} canvasWidth
1692    */
1693   calcXPos: function(canvasWidth) {
1694     this.x = canvasWidth - (DistanceMeter.dimensions.DEST_WIDTH *
1695         (this.config.MAX_DISTANCE_UNITS + 1));
1696   },
1697
1698   /**
1699    * Draw a digit to canvas.
1700    * @param {number} digitPos Position of the digit.
1701    * @param {number} value Digit value 0-9.
1702    * @param {boolean} opt_highScore Whether drawing the high score.
1703    */
1704   draw: function(digitPos, value, opt_highScore) {
1705     var sourceWidth = DistanceMeter.dimensions.WIDTH;
1706     var sourceHeight = DistanceMeter.dimensions.HEIGHT;
1707     var sourceX = DistanceMeter.dimensions.WIDTH * value;
1708
1709     var targetX = digitPos * DistanceMeter.dimensions.DEST_WIDTH;
1710     var targetY = this.y;
1711     var targetWidth = DistanceMeter.dimensions.WIDTH;
1712     var targetHeight = DistanceMeter.dimensions.HEIGHT;
1713
1714     // For high DPI we 2x source values.
1715     if (IS_HIDPI) {
1716       sourceWidth *= 2;
1717       sourceHeight *= 2;
1718       sourceX *= 2;
1719     }
1720
1721     this.canvasCtx.save();
1722
1723     if (opt_highScore) {
1724       // Left of the current score.
1725       var highScoreX = this.x - (this.config.MAX_DISTANCE_UNITS * 2) *
1726           DistanceMeter.dimensions.WIDTH;
1727       this.canvasCtx.translate(highScoreX, this.y);
1728     } else {
1729       this.canvasCtx.translate(this.x, this.y);
1730     }
1731
1732     this.canvasCtx.drawImage(this.image, sourceX, 0,
1733         sourceWidth, sourceHeight,
1734         targetX, targetY,
1735         targetWidth, targetHeight
1736       );
1737
1738     this.canvasCtx.restore();
1739   },
1740
1741   /**
1742    * Covert pixel distance to a 'real' distance.
1743    * @param {number} distance Pixel distance ran.
1744    * @return {number} The 'real' distance ran.
1745    */
1746   getActualDistance: function(distance) {
1747     return distance ?
1748         Math.round(distance * this.config.COEFFICIENT) : 0;
1749   },
1750
1751   /**
1752    * Update the distance meter.
1753    * @param {number} deltaTime
1754    * @param {number} distance
1755    * @return {boolean} Whether the acheivement sound fx should be played.
1756    */
1757   update: function(deltaTime, distance) {
1758     var paint = true;
1759     var playSound = false;
1760
1761     if (!this.acheivement) {
1762       distance = this.getActualDistance(distance);
1763
1764       if (distance > 0) {
1765         // Acheivement unlocked
1766         if (distance % this.config.ACHIEVEMENT_DISTANCE == 0) {
1767           // Flash score and play sound.
1768           this.acheivement = true;
1769           this.flashTimer = 0;
1770           playSound = true;
1771         }
1772
1773         // Create a string representation of the distance with leading 0.
1774         var distanceStr = (this.defaultString +
1775             distance).substr(-this.config.MAX_DISTANCE_UNITS);
1776         this.digits = distanceStr.split('');
1777       } else {
1778         this.digits = this.defaultString.split('');
1779       }
1780     } else {
1781       // Control flashing of the score on reaching acheivement.
1782       if (this.flashIterations <= this.config.FLASH_ITERATIONS) {
1783         this.flashTimer += deltaTime;
1784
1785         if (this.flashTimer < this.config.FLASH_DURATION) {
1786           paint = false;
1787         } else if (this.flashTimer >
1788             this.config.FLASH_DURATION * 2) {
1789           this.flashTimer = 0;
1790           this.flashIterations++;
1791         }
1792       } else {
1793         this.acheivement = false;
1794         this.flashIterations = 0;
1795         this.flashTimer = 0;
1796       }
1797     }
1798
1799     // Draw the digits if not flashing.
1800     if (paint) {
1801       for (var i = this.digits.length - 1; i >= 0; i--) {
1802         this.draw(i, parseInt(this.digits[i]));
1803       }
1804     }
1805
1806     this.drawHighScore();
1807
1808     return playSound;
1809   },
1810
1811   /**
1812    * Draw the high score.
1813    */
1814   drawHighScore: function() {
1815     this.canvasCtx.save();
1816     this.canvasCtx.globalAlpha = .8;
1817     for (var i = this.highScore.length - 1; i >= 0; i--) {
1818       this.draw(i, parseInt(this.highScore[i], 10), true);
1819     }
1820     this.canvasCtx.restore();
1821   },
1822
1823   /**
1824    * Set the highscore as a array string.
1825    * Position of char in the sprite: H - 10, I - 11.
1826    * @param {number} distance Distance ran in pixels.
1827    */
1828   setHighScore: function(distance) {
1829     distance = this.getActualDistance(distance);
1830     var highScoreStr = (this.defaultString +
1831         distance).substr(-this.config.MAX_DISTANCE_UNITS);
1832
1833     this.highScore = ['10', '11', ''].concat(highScoreStr.split(''));
1834   },
1835
1836   /**
1837    * Reset the distance meter back to '00000'.
1838    */
1839   reset: function() {
1840     this.update(0);
1841     this.acheivement = false;
1842   }
1843 };
1844
1845
1846 //******************************************************************************
1847
1848 /**
1849  * Cloud background item.
1850  * Similar to an obstacle object but without collision boxes.
1851  * @param {HTMLCanvasElement} canvas Canvas element.
1852  * @param {Image} cloudImg
1853  * @param {number} containerWidth
1854  */
1855 function Cloud(canvas, cloudImg, containerWidth) {
1856   this.canvas = canvas;
1857   this.canvasCtx = this.canvas.getContext('2d');
1858   this.image = cloudImg;
1859   this.containerWidth = containerWidth;
1860   this.xPos = containerWidth;
1861   this.yPos = 0;
1862   this.remove = false;
1863   this.cloudGap = getRandomNum(Cloud.config.MIN_CLOUD_GAP,
1864       Cloud.config.MAX_CLOUD_GAP);
1865
1866   this.init();
1867 };
1868
1869
1870 /**
1871  * Cloud object config.
1872  * @enum {number}
1873  */
1874 Cloud.config = {
1875   HEIGHT: 14,
1876   MAX_CLOUD_GAP: 400,
1877   MAX_SKY_LEVEL: 30,
1878   MIN_CLOUD_GAP: 100,
1879   MIN_SKY_LEVEL: 71,
1880   WIDTH: 46
1881 };
1882
1883
1884 Cloud.prototype = {
1885   /**
1886    * Initialise the cloud. Sets the Cloud height.
1887    */
1888   init: function() {
1889     this.yPos = getRandomNum(Cloud.config.MAX_SKY_LEVEL,
1890         Cloud.config.MIN_SKY_LEVEL);
1891     this.draw();
1892   },
1893
1894   /**
1895    * Draw the cloud.
1896    */
1897   draw: function() {
1898     this.canvasCtx.save();
1899     var sourceWidth = Cloud.config.WIDTH;
1900     var sourceHeight = Cloud.config.HEIGHT;
1901
1902     if (IS_HIDPI) {
1903       sourceWidth = sourceWidth * 2;
1904       sourceHeight = sourceHeight * 2;
1905     }
1906
1907     this.canvasCtx.drawImage(this.image, 0, 0,
1908         sourceWidth, sourceHeight,
1909         this.xPos, this.yPos,
1910         Cloud.config.WIDTH, Cloud.config.HEIGHT);
1911
1912     this.canvasCtx.restore();
1913   },
1914
1915   /**
1916    * Update the cloud position.
1917    * @param {number} speed
1918    */
1919   update: function(speed) {
1920     if (!this.remove) {
1921       this.xPos -= Math.ceil(speed);
1922       this.draw();
1923
1924       // Mark as removeable if no longer in the canvas.
1925       if (!this.isVisible()) {
1926         this.remove = true;
1927       }
1928     }
1929   },
1930
1931   /**
1932    * Check if the cloud is visible on the stage.
1933    * @return {boolean}
1934    */
1935   isVisible: function() {
1936     return this.xPos + Cloud.config.WIDTH > 0;
1937   }
1938 };
1939
1940
1941 //******************************************************************************
1942
1943 /**
1944  * Horizon Line.
1945  * Consists of two connecting lines. Randomly assigns a flat / bumpy horizon.
1946  * @param {HTMLCanvasElement} canvas
1947  * @param {HTMLImage} bgImg Horizon line sprite.
1948  * @constructor
1949  */
1950 function HorizonLine(canvas, bgImg) {
1951   this.image = bgImg;
1952   this.canvas = canvas;
1953   this.canvasCtx = canvas.getContext('2d');
1954   this.sourceDimensions = {};
1955   this.dimensions = HorizonLine.dimensions;
1956   this.sourceXPos = [0, this.dimensions.WIDTH];
1957   this.xPos = [];
1958   this.yPos = 0;
1959   this.bumpThreshold = 0.5;
1960
1961   this.setSourceDimensions();
1962   this.draw();
1963 };
1964
1965
1966 /**
1967  * Horizon line dimensions.
1968  * @enum {number}
1969  */
1970 HorizonLine.dimensions = {
1971   WIDTH: 600,
1972   HEIGHT: 12,
1973   YPOS: 127
1974 };
1975
1976
1977 HorizonLine.prototype = {
1978   /**
1979    * Set the source dimensions of the horizon line.
1980    */
1981   setSourceDimensions: function() {
1982
1983     for (var dimension in HorizonLine.dimensions) {
1984       if (IS_HIDPI) {
1985         if (dimension != 'YPOS') {
1986           this.sourceDimensions[dimension] =
1987               HorizonLine.dimensions[dimension] * 2;
1988         }
1989       } else {
1990         this.sourceDimensions[dimension] =
1991             HorizonLine.dimensions[dimension];
1992       }
1993       this.dimensions[dimension] = HorizonLine.dimensions[dimension];
1994     }
1995
1996     this.xPos = [0, HorizonLine.dimensions.WIDTH];
1997     this.yPos = HorizonLine.dimensions.YPOS;
1998   },
1999
2000   /**
2001    * Return the crop x position of a type.
2002    */
2003   getRandomType: function() {
2004     return Math.random() > this.bumpThreshold ? this.dimensions.WIDTH : 0;
2005   },
2006
2007   /**
2008    * Draw the horizon line.
2009    */
2010   draw: function() {
2011     this.canvasCtx.drawImage(this.image, this.sourceXPos[0], 0,
2012         this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT,
2013         this.xPos[0], this.yPos,
2014         this.dimensions.WIDTH, this.dimensions.HEIGHT);
2015
2016     this.canvasCtx.drawImage(this.image, this.sourceXPos[1], 0,
2017         this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT,
2018         this.xPos[1], this.yPos,
2019         this.dimensions.WIDTH, this.dimensions.HEIGHT);
2020   },
2021
2022   /**
2023    * Update the x position of an indivdual piece of the line.
2024    * @param {number} pos Line position.
2025    * @param {number} increment
2026    */
2027   updateXPos: function(pos, increment) {
2028     var line1 = pos;
2029     var line2 = pos == 0 ? 1 : 0;
2030
2031     this.xPos[line1] -= increment;
2032     this.xPos[line2] = this.xPos[line1] + this.dimensions.WIDTH;
2033
2034     if (this.xPos[line1] <= -this.dimensions.WIDTH) {
2035       this.xPos[line1] += this.dimensions.WIDTH * 2;
2036       this.xPos[line2] = this.xPos[line1] - this.dimensions.WIDTH;
2037       this.sourceXPos[line1] = this.getRandomType();
2038     }
2039   },
2040
2041   /**
2042    * Update the horizon line.
2043    * @param {number} deltaTime
2044    * @param {number} speed
2045    */
2046   update: function(deltaTime, speed) {
2047     var increment = Math.floor(speed * (FPS / 1000) * deltaTime);
2048
2049     if (this.xPos[0] <= 0) {
2050       this.updateXPos(0, increment);
2051     } else {
2052       this.updateXPos(1, increment);
2053     }
2054     this.draw();
2055   },
2056
2057   /**
2058    * Reset horizon to the starting position.
2059    */
2060   reset: function() {
2061     this.xPos[0] = 0;
2062     this.xPos[1] = HorizonLine.dimensions.WIDTH;
2063   }
2064 };
2065
2066
2067 //******************************************************************************
2068
2069 /**
2070  * Horizon background class.
2071  * @param {HTMLCanvasElement} canvas
2072  * @param {Array.<HTMLImageElement>} images
2073  * @param {object} dimensions Canvas dimensions.
2074  * @param {number} gapCoefficient
2075  * @constructor
2076  */
2077 function Horizon(canvas, images, dimensions, gapCoefficient) {
2078   this.canvas = canvas;
2079   this.canvasCtx = this.canvas.getContext('2d');
2080   this.config = Horizon.config;
2081   this.dimensions = dimensions;
2082   this.gapCoefficient = gapCoefficient;
2083   this.obstacles = [];
2084   this.horizonOffsets = [0, 0];
2085   this.cloudFrequency = this.config.CLOUD_FREQUENCY;
2086
2087   // Cloud
2088   this.clouds = [];
2089   this.cloudImg = images.CLOUD;
2090   this.cloudSpeed = this.config.BG_CLOUD_SPEED;
2091
2092   // Horizon
2093   this.horizonImg = images.HORIZON;
2094   this.horizonLine = null;
2095
2096   // Obstacles
2097   this.obstacleImgs = {
2098     CACTUS_SMALL: images.CACTUS_SMALL,
2099     CACTUS_LARGE: images.CACTUS_LARGE
2100   };
2101
2102   this.init();
2103 };
2104
2105
2106 /**
2107  * Horizon config.
2108  * @enum {number}
2109  */
2110 Horizon.config = {
2111   BG_CLOUD_SPEED: 0.2,
2112   BUMPY_THRESHOLD: .3,
2113   CLOUD_FREQUENCY: .5,
2114   HORIZON_HEIGHT: 16,
2115   MAX_CLOUDS: 6
2116 };
2117
2118
2119 Horizon.prototype = {
2120   /**
2121    * Initialise the horizon. Just add the line and a cloud. No obstacles.
2122    */
2123   init: function() {
2124     this.addCloud();
2125     this.horizonLine = new HorizonLine(this.canvas, this.horizonImg);
2126   },
2127
2128   /**
2129    * @param {number} deltaTime
2130    * @param {number} currentSpeed
2131    * @param {boolean} updateObstacles Used as an override to prevent
2132    *     the obstacles from being updated / added. This happens in the
2133    *     ease in section.
2134    */
2135   update: function(deltaTime, currentSpeed, updateObstacles) {
2136     this.runningTime += deltaTime;
2137     this.horizonLine.update(deltaTime, currentSpeed);
2138     this.updateClouds(deltaTime, currentSpeed);
2139
2140     if (updateObstacles) {
2141       this.updateObstacles(deltaTime, currentSpeed);
2142     }
2143   },
2144
2145   /**
2146    * Update the cloud positions.
2147    * @param {number} deltaTime
2148    * @param {number} currentSpeed
2149    */
2150   updateClouds: function(deltaTime, speed) {
2151     var cloudSpeed = this.cloudSpeed / 1000 * deltaTime * speed;
2152     var numClouds = this.clouds.length;
2153
2154     if (numClouds) {
2155       for (var i = numClouds - 1; i >= 0; i--) {
2156         this.clouds[i].update(cloudSpeed);
2157       }
2158
2159       var lastCloud = this.clouds[numClouds - 1];
2160
2161       // Check for adding a new cloud.
2162       if (numClouds < this.config.MAX_CLOUDS &&
2163           (this.dimensions.WIDTH - lastCloud.xPos) > lastCloud.cloudGap &&
2164           this.cloudFrequency > Math.random()) {
2165         this.addCloud();
2166       }
2167
2168       // Remove expired clouds.
2169       this.clouds = this.clouds.filter(function(obj) {
2170         return !obj.remove;
2171       });
2172     }
2173   },
2174
2175   /**
2176    * Update the obstacle positions.
2177    * @param {number} deltaTime
2178    * @param {number} currentSpeed
2179    */
2180   updateObstacles: function(deltaTime, currentSpeed) {
2181     // Obstacles, move to Horizon layer.
2182     var updatedObstacles = this.obstacles.slice(0);
2183
2184     for (var i = 0; i < this.obstacles.length; i++) {
2185       var obstacle = this.obstacles[i];
2186       obstacle.update(deltaTime, currentSpeed);
2187
2188       // Clean up existing obstacles.
2189       if (obstacle.remove) {
2190         updatedObstacles.shift();
2191       }
2192     }
2193     this.obstacles = updatedObstacles;
2194
2195     if (this.obstacles.length > 0) {
2196       var lastObstacle = this.obstacles[this.obstacles.length - 1];
2197
2198       if (lastObstacle && !lastObstacle.followingObstacleCreated &&
2199           lastObstacle.isVisible() &&
2200           (lastObstacle.xPos + lastObstacle.width + lastObstacle.gap) <
2201           this.dimensions.WIDTH) {
2202         this.addNewObstacle(currentSpeed);
2203         lastObstacle.followingObstacleCreated = true;
2204       }
2205     } else {
2206       // Create new obstacles.
2207       this.addNewObstacle(currentSpeed);
2208     }
2209   },
2210
2211   /**
2212    * Add a new obstacle.
2213    * @param {number} currentSpeed
2214    */
2215   addNewObstacle: function(currentSpeed) {
2216     var obstacleTypeIndex =
2217         getRandomNum(0, Obstacle.types.length - 1);
2218     var obstacleType = Obstacle.types[obstacleTypeIndex];
2219     var obstacleImg = this.obstacleImgs[obstacleType.type];
2220
2221     this.obstacles.push(new Obstacle(this.canvasCtx, obstacleType,
2222         obstacleImg, this.dimensions, this.gapCoefficient, currentSpeed));
2223   },
2224
2225   /**
2226    * Reset the horizon layer.
2227    * Remove existing obstacles and reposition the horizon line.
2228    */
2229   reset: function() {
2230     this.obstacles = [];
2231     this.horizonLine.reset();
2232   },
2233
2234   /**
2235    * Update the canvas width and scaling.
2236    * @param {number} width Canvas width.
2237    * @param {number} height Canvas height.
2238    */
2239   resize: function(width, height) {
2240     this.canvas.width = width;
2241     this.canvas.height = height;
2242   },
2243
2244   /**
2245    * Add a new cloud to the horizon.
2246    */
2247   addCloud: function() {
2248     this.clouds.push(new Cloud(this.canvas, this.cloudImg,
2249         this.dimensions.WIDTH));
2250   }
2251 };
2252 })();