Add testing/scripts/OWNERS
[chromium-blink-merge.git] / chrome / renderer / resources / offline.js
blob27f9d80847abfe283f635e5c5c60e2ada204da94
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;
90 /** @const */
91 var IS_IOS = window.navigator.userAgent.indexOf('CriOS') > -1;
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
119  * Default dimensions.
120  * @enum {string}
121  */
122 Runner.defaultDimensions = {
123   WIDTH: DEFAULT_WIDTH,
124   HEIGHT: 150
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'
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   ]
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'
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
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'
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;
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   },
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;
243     var numImages = imageSources.length;
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   },
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;
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);
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   },
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;
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   },
292   /**
293    * Game initialiser.
294    */
295   init: function() {
296     // Hide the static icon.
297     document.querySelector('.' + Runner.classes.ICON).style.visibility =
298         'hidden';
300     this.adjustDimensions();
301     this.setSpeed();
303     this.containerEl = document.createElement('div');
304     this.containerEl.className = Runner.classes.CONTAINER;
306     // Player canvas container.
307     this.canvas = createCanvas(this.containerEl, this.dimensions.WIDTH,
308         this.dimensions.HEIGHT, Runner.classes.PLAYER);
310     this.canvasCtx = this.canvas.getContext('2d');
311     this.canvasCtx.fillStyle = '#f7f7f7';
312     this.canvasCtx.fill();
313     Runner.updateCanvasScaling(this.canvas);
315     // Horizon contains clouds, obstacles and the ground.
316     this.horizon = new Horizon(this.canvas, this.images, this.dimensions,
317         this.config.GAP_COEFFICIENT);
319     // Distance meter
320     this.distanceMeter = new DistanceMeter(this.canvas,
321           this.images.TEXT_SPRITE, this.dimensions.WIDTH);
323     // Draw t-rex
324     this.tRex = new Trex(this.canvas, this.images.TREX);
326     this.outerContainerEl.appendChild(this.containerEl);
328     if (IS_MOBILE) {
329       this.createTouchController();
330     }
332     this.startListening();
333     this.update();
335     window.addEventListener(Runner.events.RESIZE,
336         this.debounceResize.bind(this));
337   },
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   },
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   },
357   /**
358    * Adjust game space dimensions on resize.
359    */
360   adjustDimensions: function() {
361     clearInterval(this.resizeTimerId_);
362     this.resizeTimerId_ = null;
364     var boxStyles = window.getComputedStyle(this.outerContainerEl);
365     var padding = Number(boxStyles.paddingLeft.substr(0,
366         boxStyles.paddingLeft.length - 2));
368     this.dimensions.WIDTH = this.outerContainerEl.offsetWidth - padding * 2;
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;
375       Runner.updateCanvasScaling(this.canvas);
377       this.distanceMeter.calcXPos(this.dimensions.WIDTH);
378       this.clearCanvas();
379       this.horizon.update(0, 0, true);
380       this.tRex.update(0);
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       }
392       // Game over panel.
393       if (this.crashed && this.gameOverPanel) {
394         this.gameOverPanel.updateDimensions(this.dimensions.WIDTH);
395         this.gameOverPanel.draw();
396       }
397     }
398   },
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;
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);
416       this.containerEl.addEventListener(Runner.events.ANIM_END,
417           this.startGame.bind(this));
419       this.containerEl.style.webkitAnimation = 'intro .4s ease-out 1 both';
420       this.containerEl.style.width = this.dimensions.WIDTH + 'px';
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   },
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++;
443     // Handle tabbing off the page. Pause the current game.
444     window.addEventListener(Runner.events.VISIBILITY,
445           this.onVisibilityChange.bind(this));
447     window.addEventListener(Runner.events.BLUR,
448           this.onVisibilityChange.bind(this));
450     window.addEventListener(Runner.events.FOCUS,
451           this.onVisibilityChange.bind(this));
452   },
454   clearCanvas: function() {
455     this.canvasCtx.clearRect(0, 0, this.dimensions.WIDTH,
456         this.dimensions.HEIGHT);
457   },
459   /**
460    * Update the game frame.
461    */
462   update: function() {
463     this.drawPending = false;
465     var now = getTimeStamp();
466     var deltaTime = now - (this.time || now);
467     this.time = now;
469     if (this.activated) {
470       this.clearCanvas();
472       if (this.tRex.jumping) {
473         this.tRex.updateJump(deltaTime, this.config);
474       }
476       this.runningTime += deltaTime;
477       var hasObstacles = this.runningTime > this.config.CLEAR_TIME;
479       // First jump triggers the intro.
480       if (this.tRex.jumpCount == 1 && !this.playingIntro) {
481         this.playIntro();
482       }
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       }
492       // Check for collisions.
493       var collision = hasObstacles &&
494           checkForCollision(this.horizon.obstacles[0], this.tRex);
496       if (!collision) {
497         this.distanceRan += this.currentSpeed * deltaTime / this.msPerFrame;
499         if (this.currentSpeed < this.config.MAX_SPEED) {
500           this.currentSpeed += this.config.ACCELERATION;
501         }
502       } else {
503         this.gameOver();
504       }
506       if (this.distanceMeter.getActualDistance(this.distanceRan) >
507           this.distanceMeter.maxScore) {
508         this.distanceRan = 0;
509       }
511       var playAcheivementSound = this.distanceMeter.update(deltaTime,
512           Math.ceil(this.distanceRan));
514       if (playAcheivementSound) {
515         this.playSound(this.soundFx.SCORE);
516       }
517     }
519     if (!this.crashed) {
520       this.tRex.update(deltaTime);
521       this.raq();
522     }
523   },
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   },
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);
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   },
565   /**
566    * Remove all listeners.
567    */
568   stopListening: function() {
569     document.removeEventListener(Runner.events.KEYDOWN, this);
570     document.removeEventListener(Runner.events.KEYUP, this);
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   },
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         }
595         if (!this.tRex.jumping) {
596           this.playSound(this.soundFx.BUTTON_PRESS);
597           this.tRex.startJump();
598         }
599       }
601       if (this.crashed && e.type == Runner.events.TOUCHSTART &&
602           e.currentTarget == this.containerEl) {
603         this.restart();
604       }
605     }
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   },
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;
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;
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   },
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   },
654   /**
655    * Whether the game is running.
656    * @return {boolean}
657    */
658   isRunning: function() {
659     return !!this.raqId;
660   },
662   /**
663    * Game over state.
664    */
665   gameOver: function() {
666     this.playSound(this.soundFx.HIT);
667     vibrate(200);
669     this.stop();
670     this.crashed = true;
671     this.distanceMeter.acheivement = false;
673     this.tRex.update(100, Trex.status.CRASHED);
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     }
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     }
690     // Reset the time clock.
691     this.time = getTimeStamp();
692   },
694   stop: function() {
695     this.activated = false;
696     this.paused = true;
697     cancelAnimationFrame(this.raqId);
698     this.raqId = 0;
699   },
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   },
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);
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);
728       this.update();
729     }
730   },
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   },
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   }
759  * Updates the canvas size taking into
760  * account the backing store pixel ratio and
761  * the device pixel ratio.
763  * See article by Paul Lewis:
764  * http://www.html5rocks.com/en/tutorials/canvas/hidpi/
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');
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;
779   // Upscale the canvas if the two ratios don't match
780   if (devicePixelRatio !== backingStoreRatio) {
782     var oldWidth = opt_width || canvas.width;
783     var oldHeight = opt_height || canvas.height;
785     canvas.width = oldWidth * ratio;
786     canvas.height = oldHeight * ratio;
788     canvas.style.width = oldWidth + 'px';
789     canvas.style.height = oldHeight + 'px';
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;
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;
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   }
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);
838   return canvas;
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);
852   for (var i = 0; i < len; i++) {
853     bytes[i] = str.charCodeAt(i);
854   }
855   return bytes.buffer;
860  * Return the current timestamp.
861  * @return {number}
862  */
863 function getTimeStamp() {
864   return IS_IOS ? new Date().getTime() : performance.now();
868 //******************************************************************************
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();
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
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   },
916   /**
917    * Draw the panel.
918    */
919   draw: function() {
920     var dimensions = GameOverPanel.dimensions;
922     var centerX = this.canvasDimensions.WIDTH / 2;
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;
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;
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;
940     if (IS_HIDPI) {
941       textSourceY *= 2;
942       textSourceX *= 2;
943       textSourceWidth *= 2;
944       textSourceHeight *= 2;
945       restartSourceWidth *= 2;
946       restartSourceHeight *= 2;
947     }
949     // Game over text from sprite.
950     this.canvasCtx.drawImage(this.textSprite,
951         textSourceX, textSourceY, textSourceWidth, textSourceHeight,
952         textTargetX, textTargetY, textTargetWidth, textTargetHeight);
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   }
963 //******************************************************************************
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;
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);
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);
990   // Debug outer box
991   if (opt_canvasCtx) {
992     drawCollisionBoxes(opt_canvasCtx, tRexBox, obstacleBox);
993   }
995   // Simple outer bounds check.
996   if (boxCompare(tRexBox, obstacleBox)) {
997     var collisionBoxes = obstacle.collisionBoxes;
998     var tRexCollisionBoxes = Trex.collisionBoxes;
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);
1010         // Draw boxes for debug.
1011         if (opt_canvasCtx) {
1012           drawCollisionBoxes(opt_canvasCtx, adjTrexBox, adjObstacleBox);
1013         }
1015         if (crashed) {
1016           return [adjTrexBox, adjObstacleBox];
1017         }
1018       }
1019     }
1020   }
1021   return false;
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);
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);
1049   canvasCtx.strokeStyle = '#0f0';
1050   canvasCtx.strokeRect(obstacleBox.x, obstacleBox.y,
1051   obstacleBox.width, obstacleBox.height);
1052   canvasCtx.restore();
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;
1067   var obstacleBoxX = obstacleBox.x;
1068   var obstacleBoxY = obstacleBox.y;
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   }
1078   return crashed;
1082 //******************************************************************************
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;
1099 //******************************************************************************
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) {
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;
1126   this.init(speed);
1130  * Coefficient for calculating the maximum gap.
1131  * @const
1132  */
1133 Obstacle.MAX_GAP_COEFFICIENT = 1.5;
1136  * Maximum obstacle grouping count.
1137  * @const
1138  */
1139 Obstacle.MAX_OBSTACLE_LENGTH = 3,
1142 Obstacle.prototype = {
1143   /**
1144    * Initialise the DOM for the obstacle.
1145    * @param {number} speed
1146    */
1147   init: function(speed) {
1148     this.cloneCollisionBoxes();
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     }
1155     this.width = this.typeConfig.width * this.size;
1156     this.xPos = this.dimensions.WIDTH - this.width;
1158     this.draw();
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     }
1174     this.gap = this.getGap(this.gapCoefficient, speed);
1175   },
1177   /**
1178    * Draw and crop based on size.
1179    */
1180   draw: function() {
1181     var sourceWidth = this.typeConfig.width;
1182     var sourceHeight = this.typeConfig.height;
1184     if (IS_HIDPI) {
1185       sourceWidth = sourceWidth * 2;
1186       sourceHeight = sourceHeight * 2;
1187     }
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   },
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();
1208       if (!this.isVisible()) {
1209         this.remove = true;
1210       }
1211     }
1212   },
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   },
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   },
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;
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   }
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   }
1289 //******************************************************************************
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;
1314   this.jumping = false;
1315   this.jumpVelocity = 0;
1316   this.reachedMinHeight = false;
1317   this.speedDrop = false;
1318   this.jumpCount = 0;
1319   this.jumpspotX = 0;
1321   this.init();
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
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)
1359  * Animation states.
1360  * @enum {string}
1361  */
1362 Trex.status = {
1363   CRASHED: 'CRASHED',
1364   JUMPING: 'JUMPING',
1365   RUNNING: 'RUNNING',
1366   WAITING: 'WAITING'
1370  * Blinking coefficient.
1371  * @const
1372  */
1373 Trex.BLINK_TIMING = 7000;
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   }
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;
1412     this.draw(0, 0);
1413     this.update(0, Trex.status.WAITING);
1414   },
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   },
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;
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;
1440       if (opt_status == Trex.status.WAITING) {
1441         this.animStartTime = getTimeStamp();
1442         this.setBlinkDelay();
1443       }
1444     }
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     }
1452     if (this.status == Trex.status.WAITING) {
1453       this.blink(getTimeStamp());
1454     } else {
1455       this.draw(this.currentAnimFrames[this.currentFrame], 0);
1456     }
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   },
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;
1477     if (IS_HIDPI) {
1478       sourceX *= 2;
1479       sourceY *= 2;
1480       sourceWidth *= 2;
1481       sourceHeight *= 2;
1482     }
1484     this.canvasCtx.drawImage(this.image, sourceX, sourceY,
1485         sourceWidth, sourceHeight,
1486         this.xPos, this.yPos,
1487         this.config.WIDTH, this.config.HEIGHT);
1488   },
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   },
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;
1504     if (deltaTime >= this.blinkDelay) {
1505       this.draw(this.currentAnimFrames[this.currentFrame], 0);
1507       if (this.currentFrame == 1) {
1508         // Set new random delay to blink.
1509         this.setBlinkDelay();
1510         this.animStartTime = time;
1511       }
1512     }
1513   },
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   },
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   },
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;
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     }
1554     this.jumpVelocity += this.config.GRAVITY * framesElapsed;
1556     // Minimum height has been reached.
1557     if (this.yPos < this.minJumpHeight || this.speedDrop) {
1558       this.reachedMinHeight = true;
1559     }
1561     // Reached max height
1562     if (this.yPos < this.config.MAX_JUMP_HEIGHT || this.speedDrop) {
1563       this.endJump();
1564     }
1566     // Back down at ground level. Jump completed.
1567     if (this.yPos > this.groundYPos) {
1568       this.reset();
1569       this.jumpCount++;
1570     }
1572     this.update(deltaTime);
1573   },
1575   /**
1576    * Set the speed drop. Immediately cancels the current jump.
1577    */
1578   setSpeedDrop: function() {
1579     this.speedDrop = true;
1580     this.jumpVelocity = 1;
1581   },
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   }
1598 //******************************************************************************
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;
1614   this.currentDistance = 0;
1615   this.maxScore = 0;
1616   this.highScore = 0;
1617   this.container = null;
1619   this.digits = [];
1620   this.acheivement = false;
1621   this.defaultString = '';
1622   this.flashTimer = 0;
1623   this.flashIterations = 0;
1625   this.config = DistanceMeter.config;
1626   this.init(canvasWidth);
1631  * @enum {number}
1632  */
1633 DistanceMeter.dimensions = {
1634   WIDTH: 10,
1635   HEIGHT: 13,
1636   DEST_WIDTH: 11
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];
1649  * Distance meter config.
1650  * @enum {number}
1651  */
1652 DistanceMeter.config = {
1653   // Number of digits.
1654   MAX_DISTANCE_UNITS: 5,
1656   // Distance that causes achievement animation.
1657   ACHIEVEMENT_DISTANCE: 100,
1659   // Used for conversion from pixel distance to a scaled unit.
1660   COEFFICIENT: 0.025,
1662   // Flash duration in milliseconds.
1663   FLASH_DURATION: 1000 / 4,
1665   // Flash iterations for achievement animation.
1666   FLASH_ITERATIONS: 3
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 = '';
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     }
1686     this.maxScore = parseInt(maxDistanceStr);
1687   },
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   },
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;
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;
1714     // For high DPI we 2x source values.
1715     if (IS_HIDPI) {
1716       sourceWidth *= 2;
1717       sourceHeight *= 2;
1718       sourceX *= 2;
1719     }
1721     this.canvasCtx.save();
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     }
1732     this.canvasCtx.drawImage(this.image, sourceX, 0,
1733         sourceWidth, sourceHeight,
1734         targetX, targetY,
1735         targetWidth, targetHeight
1736       );
1738     this.canvasCtx.restore();
1739   },
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   },
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;
1761     if (!this.acheivement) {
1762       distance = this.getActualDistance(distance);
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         }
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;
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     }
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     }
1806     this.drawHighScore();
1808     return playSound;
1809   },
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   },
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);
1833     this.highScore = ['10', '11', ''].concat(highScoreStr.split(''));
1834   },
1836   /**
1837    * Reset the distance meter back to '00000'.
1838    */
1839   reset: function() {
1840     this.update(0);
1841     this.acheivement = false;
1842   }
1846 //******************************************************************************
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);
1866   this.init();
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
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   },
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;
1902     if (IS_HIDPI) {
1903       sourceWidth = sourceWidth * 2;
1904       sourceHeight = sourceHeight * 2;
1905     }
1907     this.canvasCtx.drawImage(this.image, 0, 0,
1908         sourceWidth, sourceHeight,
1909         this.xPos, this.yPos,
1910         Cloud.config.WIDTH, Cloud.config.HEIGHT);
1912     this.canvasCtx.restore();
1913   },
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();
1924       // Mark as removeable if no longer in the canvas.
1925       if (!this.isVisible()) {
1926         this.remove = true;
1927       }
1928     }
1929   },
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   }
1941 //******************************************************************************
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;
1961   this.setSourceDimensions();
1962   this.draw();
1967  * Horizon line dimensions.
1968  * @enum {number}
1969  */
1970 HorizonLine.dimensions = {
1971   WIDTH: 600,
1972   HEIGHT: 12,
1973   YPOS: 127
1977 HorizonLine.prototype = {
1978   /**
1979    * Set the source dimensions of the horizon line.
1980    */
1981   setSourceDimensions: function() {
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     }
1996     this.xPos = [0, HorizonLine.dimensions.WIDTH];
1997     this.yPos = HorizonLine.dimensions.YPOS;
1998   },
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   },
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);
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   },
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;
2031     this.xPos[line1] -= increment;
2032     this.xPos[line2] = this.xPos[line1] + this.dimensions.WIDTH;
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   },
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);
2049     if (this.xPos[0] <= 0) {
2050       this.updateXPos(0, increment);
2051     } else {
2052       this.updateXPos(1, increment);
2053     }
2054     this.draw();
2055   },
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   }
2067 //******************************************************************************
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;
2087   // Cloud
2088   this.clouds = [];
2089   this.cloudImg = images.CLOUD;
2090   this.cloudSpeed = this.config.BG_CLOUD_SPEED;
2092   // Horizon
2093   this.horizonImg = images.HORIZON;
2094   this.horizonLine = null;
2096   // Obstacles
2097   this.obstacleImgs = {
2098     CACTUS_SMALL: images.CACTUS_SMALL,
2099     CACTUS_LARGE: images.CACTUS_LARGE
2100   };
2102   this.init();
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
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   },
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);
2140     if (updateObstacles) {
2141       this.updateObstacles(deltaTime, currentSpeed);
2142     }
2143   },
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;
2154     if (numClouds) {
2155       for (var i = numClouds - 1; i >= 0; i--) {
2156         this.clouds[i].update(cloudSpeed);
2157       }
2159       var lastCloud = this.clouds[numClouds - 1];
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       }
2168       // Remove expired clouds.
2169       this.clouds = this.clouds.filter(function(obj) {
2170         return !obj.remove;
2171       });
2172     }
2173   },
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);
2184     for (var i = 0; i < this.obstacles.length; i++) {
2185       var obstacle = this.obstacles[i];
2186       obstacle.update(deltaTime, currentSpeed);
2188       // Clean up existing obstacles.
2189       if (obstacle.remove) {
2190         updatedObstacles.shift();
2191       }
2192     }
2193     this.obstacles = updatedObstacles;
2195     if (this.obstacles.length > 0) {
2196       var lastObstacle = this.obstacles[this.obstacles.length - 1];
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   },
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];
2221     this.obstacles.push(new Obstacle(this.canvasCtx, obstacleType,
2222         obstacleImg, this.dimensions, this.gapCoefficient, currentSpeed));
2223   },
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   },
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   },
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   }
2252 })();