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