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