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