| Index: chrome/renderer/resources/offline.js | 
| diff --git a/chrome/renderer/resources/offline.js b/chrome/renderer/resources/offline.js | 
| index 5fabe448cf567c1a09b82eef58a5c1933fffa751..5e6c6b348615f8a02f15c4ff5dc4807bf016a6b9 100644 | 
| --- a/chrome/renderer/resources/offline.js | 
| +++ b/chrome/renderer/resources/offline.js | 
| @@ -6,7 +6,7 @@ | 
| /** | 
| * T-Rex runner. | 
| * @param {string} outerContainerId Outer containing element id. | 
| - * @param {object} opt_config | 
| + * @param {Object} opt_config | 
| * @constructor | 
| * @export | 
| */ | 
| @@ -19,6 +19,7 @@ function Runner(outerContainerId, opt_config) { | 
|  | 
| this.outerContainerEl = document.querySelector(outerContainerId); | 
| this.containerEl = null; | 
| +  this.snackbarEl = null; | 
| this.detailsButton = this.outerContainerEl.querySelector('#details-button'); | 
|  | 
| this.config = opt_config || Runner.config; | 
| @@ -87,8 +88,8 @@ var FPS = 60; | 
| var IS_HIDPI = window.devicePixelRatio > 1; | 
|  | 
| /** @const */ | 
| -var IS_IOS = | 
| -    window.navigator.userAgent.indexOf('UIWebViewForStaticFileContent') > -1; | 
| +var IS_IOS = window.navigator.userAgent.indexOf('CriOS') > -1 || | 
| +    window.navigator.userAgent == 'UIWebViewForStaticFileContent'; | 
|  | 
| /** @const */ | 
| var IS_MOBILE = window.navigator.userAgent.indexOf('Mobi') > -1 || IS_IOS; | 
| @@ -112,7 +113,8 @@ Runner.config = { | 
| INITIAL_JUMP_VELOCITY: 12, | 
| MAX_CLOUDS: 6, | 
| MAX_OBSTACLE_LENGTH: 3, | 
| -  MAX_SPEED: 12, | 
| +  MAX_OBSTACLE_DUPLICATION: 2, | 
| +  MAX_SPEED: 13, | 
| MIN_JUMP_HEIGHT: 35, | 
| MOBILE_SPEED_COEFFICIENT: 1.2, | 
| RESOURCE_TEMPLATE_ID: 'audio-resources', | 
| @@ -147,28 +149,30 @@ Runner.classes = { | 
|  | 
|  | 
| /** | 
| - * Image source urls. | 
| - * @enum {array<object>} | 
| + * Sprite definition layout of the spritesheet. | 
| + * @enum {Object} | 
| */ | 
| -Runner.imageSources = { | 
| -  LDPI: [ | 
| -    {name: 'CACTUS_LARGE', id: '1x-obstacle-large'}, | 
| -    {name: 'CACTUS_SMALL', id: '1x-obstacle-small'}, | 
| -    {name: 'CLOUD', id: '1x-cloud'}, | 
| -    {name: 'HORIZON', id: '1x-horizon'}, | 
| -    {name: 'RESTART', id: '1x-restart'}, | 
| -    {name: 'TEXT_SPRITE', id: '1x-text'}, | 
| -    {name: 'TREX', id: '1x-trex'} | 
| -  ], | 
| -  HDPI: [ | 
| -    {name: 'CACTUS_LARGE', id: '2x-obstacle-large'}, | 
| -    {name: 'CACTUS_SMALL', id: '2x-obstacle-small'}, | 
| -    {name: 'CLOUD', id: '2x-cloud'}, | 
| -    {name: 'HORIZON', id: '2x-horizon'}, | 
| -    {name: 'RESTART', id: '2x-restart'}, | 
| -    {name: 'TEXT_SPRITE', id: '2x-text'}, | 
| -    {name: 'TREX', id: '2x-trex'} | 
| -  ] | 
| +Runner.spriteDefinition = { | 
| +  LDPI: { | 
| +    CACTUS_LARGE: {x: 332, y: 2}, | 
| +    CACTUS_SMALL: {x: 228, y: 2}, | 
| +    CLOUD: {x: 86, y: 2}, | 
| +    HORIZON: {x: 2, y: 54}, | 
| +    PTERODACTYL: {x: 134, y: 2}, | 
| +    RESTART: {x: 2, y: 2}, | 
| +    TEXT_SPRITE: {x: 484, y: 2}, | 
| +    TREX: {x: 677, y: 2} | 
| +  }, | 
| +  HDPI: { | 
| +    CACTUS_LARGE: {x: 652,y: 2}, | 
| +    CACTUS_SMALL: {x: 446,y: 2}, | 
| +    CLOUD: {x: 166,y: 2}, | 
| +    HORIZON: {x: 2,y: 104}, | 
| +    PTERODACTYL: {x: 260,y: 2}, | 
| +    RESTART: {x: 2,y: 2}, | 
| +    TEXT_SPRITE: {x: 954,y: 2}, | 
| +    TREX: {x: 1338,y: 2} | 
| +  } | 
| }; | 
|  | 
|  | 
| @@ -185,7 +189,7 @@ Runner.sounds = { | 
|  | 
| /** | 
| * Key code mapping. | 
| - * @enum {object} | 
| + * @enum {Object} | 
| */ | 
| Runner.keycodes = { | 
| JUMP: {'38': 1, '32': 1},  // Up, spacebar | 
| @@ -268,18 +272,18 @@ Runner.prototype = { | 
| }, | 
|  | 
| /** | 
| -   * Load and cache the image assets from the page. | 
| +   * Cache the appropriate image sprite from the page and get the sprite sheet | 
| +   * definition. | 
| */ | 
| loadImages: function() { | 
| -    var imageSources = IS_HIDPI ? Runner.imageSources.HDPI : | 
| -        Runner.imageSources.LDPI; | 
| - | 
| -    var numImages = imageSources.length; | 
| - | 
| -    for (var i = numImages - 1; i >= 0; i--) { | 
| -      var imgSource = imageSources[i]; | 
| -      this.images[imgSource.name] = document.getElementById(imgSource.id); | 
| +    if (IS_HIDPI) { | 
| +      Runner.imageSprite = document.getElementById('offline-resources-2x'); | 
| +      this.spriteDef = Runner.spriteDefinition.HDPI; | 
| +    } else { | 
| +      Runner.imageSprite = document.getElementById('offline-resources-1x'); | 
| +      this.spriteDef = Runner.spriteDefinition.LDPI; | 
| } | 
| + | 
| this.init(); | 
| }, | 
|  | 
| @@ -289,6 +293,7 @@ Runner.prototype = { | 
| loadSounds: function() { | 
| if (!IS_IOS) { | 
| this.audioContext = new AudioContext(); | 
| + | 
| var resourceTemplate = | 
| document.getElementById(this.config.RESOURCE_TEMPLATE_ID).content; | 
|  | 
| @@ -347,15 +352,15 @@ Runner.prototype = { | 
| Runner.updateCanvasScaling(this.canvas); | 
|  | 
| // Horizon contains clouds, obstacles and the ground. | 
| -    this.horizon = new Horizon(this.canvas, this.images, this.dimensions, | 
| +    this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, | 
| this.config.GAP_COEFFICIENT); | 
|  | 
| // Distance meter | 
| this.distanceMeter = new DistanceMeter(this.canvas, | 
| -          this.images.TEXT_SPRITE, this.dimensions.WIDTH); | 
| +          this.spriteDef.TEXT_SPRITE, this.dimensions.WIDTH); | 
|  | 
| // Draw t-rex | 
| -    this.tRex = new Trex(this.canvas, this.images.TREX); | 
| +    this.tRex = new Trex(this.canvas, this.spriteDef.TREX); | 
|  | 
| this.outerContainerEl.appendChild(this.containerEl); | 
|  | 
| @@ -504,7 +509,7 @@ Runner.prototype = { | 
| this.clearCanvas(); | 
|  | 
| if (this.tRex.jumping) { | 
| -        this.tRex.updateJump(deltaTime, this.config); | 
| +        this.tRex.updateJump(deltaTime); | 
| } | 
|  | 
| this.runningTime += deltaTime; | 
| @@ -537,11 +542,6 @@ Runner.prototype = { | 
| this.gameOver(); | 
| } | 
|  | 
| -      if (this.distanceMeter.getActualDistance(this.distanceRan) > | 
| -          this.distanceMeter.maxScore) { | 
| -        this.distanceRan = 0; | 
| -      } | 
| - | 
| var playAcheivementSound = this.distanceMeter.update(deltaTime, | 
| Math.ceil(this.distanceRan)); | 
|  | 
| @@ -618,17 +618,23 @@ Runner.prototype = { | 
| * @param {Event} e | 
| */ | 
| onKeyDown: function(e) { | 
| +    // Prevent native page scrolling whilst tapping on mobile. | 
| +    if (IS_MOBILE) { | 
| +      e.preventDefault(); | 
| +    } | 
| + | 
| if (e.target != this.detailsButton) { | 
| -      if (!this.crashed && (Runner.keycodes.JUMP[String(e.keyCode)] || | 
| +      if (!this.crashed && (Runner.keycodes.JUMP[e.keyCode] || | 
| e.type == Runner.events.TOUCHSTART)) { | 
| if (!this.activated) { | 
| this.loadSounds(); | 
| this.activated = true; | 
| +          errorPageController.trackEasterEgg(); | 
| } | 
|  | 
| -        if (!this.tRex.jumping) { | 
| +        if (!this.tRex.jumping && !this.tRex.ducking) { | 
| this.playSound(this.soundFx.BUTTON_PRESS); | 
| -          this.tRex.startJump(); | 
| +          this.tRex.startJump(this.currentSpeed); | 
| } | 
| } | 
|  | 
| @@ -638,10 +644,15 @@ Runner.prototype = { | 
| } | 
| } | 
|  | 
| -    // Speed drop, activated only when jump key is not pressed. | 
| -    if (Runner.keycodes.DUCK[e.keyCode] && this.tRex.jumping) { | 
| +    if (this.activated && !this.crashed && Runner.keycodes.DUCK[e.keyCode]) { | 
| e.preventDefault(); | 
| -      this.tRex.setSpeedDrop(); | 
| +      if (this.tRex.jumping) { | 
| +        // Speed drop, activated only when jump key is not pressed. | 
| +        this.tRex.setSpeedDrop(); | 
| +      } else if (!this.tRex.jumping && !this.tRex.ducking) { | 
| +        // Duck. | 
| +        this.tRex.setDuck(true); | 
| +      } | 
| } | 
| }, | 
|  | 
| @@ -660,22 +671,35 @@ Runner.prototype = { | 
| this.tRex.endJump(); | 
| } else if (Runner.keycodes.DUCK[keyCode]) { | 
| this.tRex.speedDrop = false; | 
| +      this.tRex.setDuck(false); | 
| } else if (this.crashed) { | 
| // Check that enough time has elapsed before allowing jump key to restart. | 
| var deltaTime = getTimeStamp() - this.time; | 
|  | 
| -      if (Runner.keycodes.RESTART[keyCode] || | 
| -         (e.type == Runner.events.MOUSEUP && e.target == this.canvas) || | 
| -         (deltaTime >= this.config.GAMEOVER_CLEAR_TIME && | 
| -         Runner.keycodes.JUMP[keyCode])) { | 
| +      if (Runner.keycodes.RESTART[keyCode] || this.isLeftClickOnCanvas(e) || | 
| +          (deltaTime >= this.config.GAMEOVER_CLEAR_TIME && | 
| +          Runner.keycodes.JUMP[keyCode])) { | 
| this.restart(); | 
| } | 
| } else if (this.paused && isjumpKey) { | 
| +      // Reset the jump state | 
| +      this.tRex.reset(); | 
| this.play(); | 
| } | 
| }, | 
|  | 
| /** | 
| +   * Returns whether the event was a left click on canvas. | 
| +   * On Windows right click is registered as a click. | 
| +   * @param {Event} e | 
| +   * @return {boolean} | 
| +   */ | 
| +  isLeftClickOnCanvas: function(e) { | 
| +    return e.button && e.button < 2 && e.type == Runner.events.MOUSEUP && | 
| +        e.target == this.canvas; | 
| +  }, | 
| + | 
| +  /** | 
| * RequestAnimationFrame wrapper. | 
| */ | 
| raq: function() { | 
| @@ -709,7 +733,7 @@ Runner.prototype = { | 
| // Game over panel. | 
| if (!this.gameOverPanel) { | 
| this.gameOverPanel = new GameOverPanel(this.canvas, | 
| -          this.images.TEXT_SPRITE, this.images.RESTART, | 
| +          this.spriteDef.TEXT_SPRITE, this.spriteDef.RESTART, | 
| this.dimensions); | 
| } else { | 
| this.gameOverPanel.draw(); | 
| @@ -812,7 +836,6 @@ Runner.updateCanvasScaling = function(canvas, opt_width, opt_height) { | 
|  | 
| // Upscale the canvas if the two ratios don't match | 
| if (devicePixelRatio !== backingStoreRatio) { | 
| - | 
| var oldWidth = opt_width || canvas.width; | 
| var oldHeight = opt_height || canvas.height; | 
|  | 
| @@ -910,17 +933,17 @@ function getTimeStamp() { | 
| /** | 
| * Game over panel. | 
| * @param {!HTMLCanvasElement} canvas | 
| - * @param {!HTMLImage} textSprite | 
| - * @param {!HTMLImage} restartImg | 
| + * @param {Object} textImgPos | 
| + * @param {Object} restartImgPos | 
| * @param {!Object} dimensions Canvas dimensions. | 
| * @constructor | 
| */ | 
| -function GameOverPanel(canvas, textSprite, restartImg, dimensions) { | 
| +function GameOverPanel(canvas, textImgPos, restartImgPos, dimensions) { | 
| this.canvas = canvas; | 
| this.canvasCtx = canvas.getContext('2d'); | 
| this.canvasDimensions = dimensions; | 
| -  this.textSprite = textSprite; | 
| -  this.restartImg = restartImg; | 
| +  this.textImgPos = textImgPos; | 
| +  this.restartImgPos = restartImgPos; | 
| this.draw(); | 
| }; | 
|  | 
| @@ -985,13 +1008,17 @@ GameOverPanel.prototype = { | 
| restartSourceHeight *= 2; | 
| } | 
|  | 
| +    textSourceX += this.textImgPos.x; | 
| +    textSourceY += this.textImgPos.y; | 
| + | 
| // Game over text from sprite. | 
| -    this.canvasCtx.drawImage(this.textSprite, | 
| +    this.canvasCtx.drawImage(Runner.imageSprite, | 
| textSourceX, textSourceY, textSourceWidth, textSourceHeight, | 
| textTargetX, textTargetY, textTargetWidth, textTargetHeight); | 
|  | 
| // Restart button. | 
| -    this.canvasCtx.drawImage(this.restartImg, 0, 0, | 
| +    this.canvasCtx.drawImage(Runner.imageSprite, | 
| +        this.restartImgPos.x, this.restartImgPos.y, | 
| restartSourceWidth, restartSourceHeight, | 
| restartTargetX, restartTargetY, dimensions.RESTART_WIDTH, | 
| dimensions.RESTART_HEIGHT); | 
| @@ -1034,7 +1061,8 @@ function checkForCollision(obstacle, tRex, opt_canvasCtx) { | 
| // Simple outer bounds check. | 
| if (boxCompare(tRexBox, obstacleBox)) { | 
| var collisionBoxes = obstacle.collisionBoxes; | 
| -    var tRexCollisionBoxes = Trex.collisionBoxes; | 
| +    var tRexCollisionBoxes = tRex.ducking ? | 
| +        Trex.collisionBoxes.DUCKING : Trex.collisionBoxes.RUNNING; | 
|  | 
| // Detailed axis aligned box check. | 
| for (var t = 0; t < tRexCollisionBoxes.length; t++) { | 
| @@ -1082,12 +1110,11 @@ function createAdjustedCollisionBox(box, adjustment) { | 
| function drawCollisionBoxes(canvasCtx, tRexBox, obstacleBox) { | 
| canvasCtx.save(); | 
| canvasCtx.strokeStyle = '#f00'; | 
| -  canvasCtx.strokeRect(tRexBox.x, tRexBox.y, | 
| -  tRexBox.width, tRexBox.height); | 
| +  canvasCtx.strokeRect(tRexBox.x, tRexBox.y, tRexBox.width, tRexBox.height); | 
|  | 
| canvasCtx.strokeStyle = '#0f0'; | 
| canvasCtx.strokeRect(obstacleBox.x, obstacleBox.y, | 
| -  obstacleBox.width, obstacleBox.height); | 
| +      obstacleBox.width, obstacleBox.height); | 
| canvasCtx.restore(); | 
| }; | 
|  | 
| @@ -1141,26 +1168,31 @@ function CollisionBox(x, y, w, h) { | 
| * Obstacle. | 
| * @param {HTMLCanvasCtx} canvasCtx | 
| * @param {Obstacle.type} type | 
| - * @param {image} obstacleImg Image sprite. | 
| + * @param {Object} spritePos Obstacle position in sprite. | 
| * @param {Object} dimensions | 
| * @param {number} gapCoefficient Mutipler in determining the gap. | 
| * @param {number} speed | 
| */ | 
| -function Obstacle(canvasCtx, type, obstacleImg, dimensions, | 
| +function Obstacle(canvasCtx, type, spriteImgPos, dimensions, | 
| gapCoefficient, speed) { | 
|  | 
| this.canvasCtx = canvasCtx; | 
| -  this.image = obstacleImg; | 
| +  this.spritePos = spriteImgPos; | 
| this.typeConfig = type; | 
| this.gapCoefficient = gapCoefficient; | 
| this.size = getRandomNum(1, Obstacle.MAX_OBSTACLE_LENGTH); | 
| this.dimensions = dimensions; | 
| this.remove = false; | 
| this.xPos = 0; | 
| -  this.yPos = this.typeConfig.yPos; | 
| +  this.yPos = 0; | 
| this.width = 0; | 
| this.collisionBoxes = []; | 
| this.gap = 0; | 
| +  this.speedOffset = 0; | 
| + | 
| +  // For animated obstacles. | 
| +  this.currentFrame = 0; | 
| +  this.timer = 0; | 
|  | 
| this.init(speed); | 
| }; | 
| @@ -1194,6 +1226,15 @@ Obstacle.prototype = { | 
| this.width = this.typeConfig.width * this.size; | 
| this.xPos = this.dimensions.WIDTH - this.width; | 
|  | 
| +    // Check if obstacle can be positioned at various heights. | 
| +    if (Array.isArray(this.typeConfig.yPos))  { | 
| +      var yPosConfig = IS_MOBILE ? this.typeConfig.yPosMobile : | 
| +          this.typeConfig.yPos; | 
| +      this.yPos = yPosConfig[getRandomNum(0, yPosConfig.length - 1)]; | 
| +    } else { | 
| +      this.yPos = this.typeConfig.yPos; | 
| +    } | 
| + | 
| this.draw(); | 
|  | 
| // Make collision box adjustments, | 
| @@ -1210,6 +1251,12 @@ Obstacle.prototype = { | 
| this.collisionBoxes[2].x = this.width - this.collisionBoxes[2].width; | 
| } | 
|  | 
| +    // For obstacles that go at a different speed from the horizon. | 
| +    if (this.typeConfig.speedOffset) { | 
| +      this.speedOffset = Math.random() > 0.5 ? this.typeConfig.speedOffset : | 
| +          -this.typeConfig.speedOffset; | 
| +    } | 
| + | 
| this.gap = this.getGap(this.gapCoefficient, speed); | 
| }, | 
|  | 
| @@ -1225,10 +1272,17 @@ Obstacle.prototype = { | 
| sourceHeight = sourceHeight * 2; | 
| } | 
|  | 
| -    // Sprite | 
| -    var sourceX = (sourceWidth * this.size) * (0.5 * (this.size - 1)); | 
| -    this.canvasCtx.drawImage(this.image, | 
| -      sourceX, 0, | 
| +    // X position in sprite. | 
| +    var sourceX = (sourceWidth * this.size) * (0.5 * (this.size - 1)) + | 
| +        this.spritePos.x; | 
| + | 
| +    // Animation frames. | 
| +    if (this.currentFrame > 0) { | 
| +      sourceX += sourceWidth * this.currentFrame; | 
| +    } | 
| + | 
| +    this.canvasCtx.drawImage(Runner.imageSprite, | 
| +      sourceX, this.spritePos.y, | 
| sourceWidth * this.size, sourceHeight, | 
| this.xPos, this.yPos, | 
| this.typeConfig.width * this.size, this.typeConfig.height); | 
| @@ -1241,7 +1295,21 @@ Obstacle.prototype = { | 
| */ | 
| update: function(deltaTime, speed) { | 
| if (!this.remove) { | 
| +      if (this.typeConfig.speedOffset) { | 
| +        speed += this.speedOffset; | 
| +      } | 
| this.xPos -= Math.floor((speed * FPS / 1000) * deltaTime); | 
| + | 
| +      // Update frame | 
| +      if (this.typeConfig.numFrames) { | 
| +        this.timer += deltaTime; | 
| +        if (this.timer >= this.typeConfig.frameRate) { | 
| +          this.currentFrame = | 
| +              this.currentFrame == this.typeConfig.numFrames - 1 ? | 
| +              0 : this.currentFrame + 1; | 
| +          this.timer = 0; | 
| +        } | 
| +      } | 
| this.draw(); | 
|  | 
| if (!this.isVisible()) { | 
| @@ -1292,16 +1360,18 @@ Obstacle.prototype = { | 
| * Obstacle definitions. | 
| * minGap: minimum pixel space betweeen obstacles. | 
| * multipleSpeed: Speed at which multiples are allowed. | 
| + * speedOffset: speed faster / slower than the horizon. | 
| + * minSpeed: Minimum speed which the obstacle can make an appearance. | 
| */ | 
| Obstacle.types = [ | 
| { | 
| type: 'CACTUS_SMALL', | 
| -    className: ' cactus cactus-small ', | 
| width: 17, | 
| height: 35, | 
| yPos: 105, | 
| -    multipleSpeed: 3, | 
| +    multipleSpeed: 4, | 
| minGap: 120, | 
| +    minSpeed: 0, | 
| collisionBoxes: [ | 
| new CollisionBox(0, 7, 5, 27), | 
| new CollisionBox(4, 0, 6, 34), | 
| @@ -1310,17 +1380,37 @@ Obstacle.types = [ | 
| }, | 
| { | 
| type: 'CACTUS_LARGE', | 
| -    className: ' cactus cactus-large ', | 
| width: 25, | 
| height: 50, | 
| yPos: 90, | 
| -    multipleSpeed: 6, | 
| +    multipleSpeed: 7, | 
| minGap: 120, | 
| +    minSpeed: 0, | 
| collisionBoxes: [ | 
| new CollisionBox(0, 12, 7, 38), | 
| new CollisionBox(8, 0, 7, 49), | 
| new CollisionBox(13, 10, 10, 38) | 
| ] | 
| +  }, | 
| +  { | 
| +    type: 'PTERODACTYL', | 
| +    width: 46, | 
| +    height: 40, | 
| +    yPos: [ 100, 75, 50 ], // Variable height. | 
| +    yPosMobile: [ 100, 50 ], // Variable height mobile. | 
| +    multipleSpeed: 999, | 
| +    minSpeed: 8.5, | 
| +    minGap: 150, | 
| +    collisionBoxes: [ | 
| +      new CollisionBox(15, 15, 16, 5), | 
| +      new CollisionBox(18, 21, 24, 6), | 
| +      new CollisionBox(2, 14, 4, 3), | 
| +      new CollisionBox(6, 10, 4, 7), | 
| +      new CollisionBox(10, 8, 6, 9) | 
| +    ], | 
| +    numFrames: 2, | 
| +    frameRate: 1000/6, | 
| +    speedOffset: .8 | 
| } | 
| ]; | 
|  | 
| @@ -1329,13 +1419,13 @@ Obstacle.types = [ | 
| /** | 
| * T-rex game character. | 
| * @param {HTMLCanvas} canvas | 
| - * @param {HTMLImage} image Character image. | 
| + * @param {Object} spritePos Positioning within image sprite. | 
| * @constructor | 
| */ | 
| -function Trex(canvas, image) { | 
| +function Trex(canvas, spritePos) { | 
| this.canvas = canvas; | 
| this.canvasCtx = canvas.getContext('2d'); | 
| -  this.image = image; | 
| +  this.spritePos = spritePos; | 
| this.xPos = 0; | 
| this.yPos = 0; | 
| // Position when on the ground. | 
| @@ -1351,6 +1441,7 @@ function Trex(canvas, image) { | 
| this.status = Trex.status.WAITING; | 
|  | 
| this.jumping = false; | 
| +  this.ducking = false; | 
| this.jumpVelocity = 0; | 
| this.reachedMinHeight = false; | 
| this.speedDrop = false; | 
| @@ -1369,6 +1460,7 @@ Trex.config = { | 
| DROP_VELOCITY: -5, | 
| GRAVITY: 0.6, | 
| HEIGHT: 47, | 
| +  HEIGHT_DUCK: 25, | 
| INIITAL_JUMP_VELOCITY: -10, | 
| INTRO_DURATION: 1500, | 
| MAX_JUMP_HEIGHT: 30, | 
| @@ -1376,7 +1468,8 @@ Trex.config = { | 
| SPEED_DROP_COEFFICIENT: 3, | 
| SPRITE_WIDTH: 262, | 
| START_X_POS: 50, | 
| -  WIDTH: 44 | 
| +  WIDTH: 44, | 
| +  WIDTH_DUCK: 59 | 
| }; | 
|  | 
|  | 
| @@ -1384,14 +1477,19 @@ Trex.config = { | 
| * Used in collision detection. | 
| * @type {Array<CollisionBox>} | 
| */ | 
| -Trex.collisionBoxes = [ | 
| -  new CollisionBox(1, -1, 30, 26), | 
| -  new CollisionBox(32, 0, 8, 16), | 
| -  new CollisionBox(10, 35, 14, 8), | 
| -  new CollisionBox(1, 24, 29, 5), | 
| -  new CollisionBox(5, 30, 21, 4), | 
| -  new CollisionBox(9, 34, 15, 4) | 
| -]; | 
| +Trex.collisionBoxes = { | 
| +  DUCKING: [ | 
| +    new CollisionBox(1, 18, 55, 25) | 
| +  ], | 
| +  RUNNING: [ | 
| +    new CollisionBox(22, 0, 17, 16), | 
| +    new CollisionBox(1, 18, 30, 9), | 
| +    new CollisionBox(10, 35, 14, 8), | 
| +    new CollisionBox(1, 24, 29, 5), | 
| +    new CollisionBox(5, 30, 21, 4), | 
| +    new CollisionBox(9, 34, 15, 4) | 
| +  ] | 
| +}; | 
|  | 
|  | 
| /** | 
| @@ -1400,6 +1498,7 @@ Trex.collisionBoxes = [ | 
| */ | 
| Trex.status = { | 
| CRASHED: 'CRASHED', | 
| +  DUCKING: 'DUCKING', | 
| JUMPING: 'JUMPING', | 
| RUNNING: 'RUNNING', | 
| WAITING: 'WAITING' | 
| @@ -1414,7 +1513,7 @@ Trex.BLINK_TIMING = 7000; | 
|  | 
| /** | 
| * Animation config for different states. | 
| - * @enum {object} | 
| + * @enum {Object} | 
| */ | 
| Trex.animFrames = { | 
| WAITING: { | 
| @@ -1432,6 +1531,10 @@ Trex.animFrames = { | 
| JUMPING: { | 
| frames: [0], | 
| msPerFrame: 1000 / 60 | 
| +  }, | 
| +  DUCKING: { | 
| +    frames: [262, 321], | 
| +    msPerFrame: 1000 / 8 | 
| } | 
| }; | 
|  | 
| @@ -1500,6 +1603,12 @@ Trex.prototype = { | 
| this.currentAnimFrames.length - 1 ? 0 : this.currentFrame + 1; | 
| this.timer = 0; | 
| } | 
| + | 
| +    // Speed drop becomes duck if the down key is still being pressed. | 
| +    if (this.speedDrop && this.yPos == this.groundYPos) { | 
| +      this.speedDrop = false; | 
| +      this.setDuck(true); | 
| +    } | 
| }, | 
|  | 
| /** | 
| @@ -1510,7 +1619,8 @@ Trex.prototype = { | 
| draw: function(x, y) { | 
| var sourceX = x; | 
| var sourceY = y; | 
| -    var sourceWidth = this.config.WIDTH; | 
| +    var sourceWidth = this.ducking && this.status != Trex.status.CRASHED ? | 
| +        this.config.WIDTH_DUCK : this.config.WIDTH; | 
| var sourceHeight = this.config.HEIGHT; | 
|  | 
| if (IS_HIDPI) { | 
| @@ -1520,10 +1630,27 @@ Trex.prototype = { | 
| sourceHeight *= 2; | 
| } | 
|  | 
| -    this.canvasCtx.drawImage(this.image, sourceX, sourceY, | 
| -        sourceWidth, sourceHeight, | 
| -        this.xPos, this.yPos, | 
| -        this.config.WIDTH, this.config.HEIGHT); | 
| +    // Adjustments for sprite sheet position. | 
| +    sourceX += this.spritePos.x; | 
| +    sourceY += this.spritePos.y; | 
| + | 
| +    // Ducking. | 
| +    if (this.ducking && this.status != Trex.status.CRASHED) { | 
| +      this.canvasCtx.drawImage(Runner.imageSprite, sourceX, sourceY, | 
| +          sourceWidth, sourceHeight, | 
| +          this.xPos, this.yPos, | 
| +          this.config.WIDTH_DUCK, this.config.HEIGHT); | 
| +    } else { | 
| +      // Crashed whilst ducking. Trex is standing up so needs adjustment. | 
| +      if (this.ducking && this.status == Trex.status.CRASHED) { | 
| +        this.xPos++; | 
| +      } | 
| +      // Standing / running | 
| +      this.canvasCtx.drawImage(Runner.imageSprite, sourceX, sourceY, | 
| +          sourceWidth, sourceHeight, | 
| +          this.xPos, this.yPos, | 
| +          this.config.WIDTH, this.config.HEIGHT); | 
| +    } | 
| }, | 
|  | 
| /** | 
| @@ -1553,11 +1680,13 @@ Trex.prototype = { | 
|  | 
| /** | 
| * Initialise a jump. | 
| +   * @param {number} speed | 
| */ | 
| -  startJump: function() { | 
| +  startJump: function(speed) { | 
| if (!this.jumping) { | 
| this.update(0, Trex.status.JUMPING); | 
| -      this.jumpVelocity = this.config.INIITAL_JUMP_VELOCITY; | 
| +      // Tweak the jump velocity based on the speed. | 
| +      this.jumpVelocity = this.config.INIITAL_JUMP_VELOCITY - (speed / 10); | 
| this.jumping = true; | 
| this.reachedMinHeight = false; | 
| this.speedDrop = false; | 
| @@ -1577,8 +1706,9 @@ Trex.prototype = { | 
| /** | 
| * Update frame for a jump. | 
| * @param {number} deltaTime | 
| +   * @param {number} speed | 
| */ | 
| -  updateJump: function(deltaTime) { | 
| +  updateJump: function(deltaTime, speed) { | 
| var msPerFrame = Trex.animFrames[this.status].msPerFrame; | 
| var framesElapsed = deltaTime / msPerFrame; | 
|  | 
| @@ -1620,12 +1750,26 @@ Trex.prototype = { | 
| }, | 
|  | 
| /** | 
| +   * @param {boolean} isDucking. | 
| +   */ | 
| +  setDuck: function(isDucking) { | 
| +    if (isDucking && this.status != Trex.status.DUCKING) { | 
| +      this.update(0, Trex.status.DUCKING); | 
| +      this.ducking = true; | 
| +    } else if (this.status == Trex.status.DUCKING) { | 
| +      this.update(0, Trex.status.RUNNING); | 
| +      this.ducking = false; | 
| +    } | 
| +  }, | 
| + | 
| +  /** | 
| * Reset the t-rex to running at start of game. | 
| */ | 
| reset: function() { | 
| this.yPos = this.groundYPos; | 
| this.jumpVelocity = 0; | 
| this.jumping = false; | 
| +    this.ducking = false; | 
| this.update(0, Trex.status.RUNNING); | 
| this.midair = false; | 
| this.speedDrop = false; | 
| @@ -1639,14 +1783,15 @@ Trex.prototype = { | 
| /** | 
| * Handles displaying the distance meter. | 
| * @param {!HTMLCanvasElement} canvas | 
| - * @param {!HTMLImage} spriteSheet Image sprite. | 
| + * @param {Object} spritePos Image position in sprite. | 
| * @param {number} canvasWidth | 
| * @constructor | 
| */ | 
| -function DistanceMeter(canvas, spriteSheet, canvasWidth) { | 
| +function DistanceMeter(canvas, spritePos, canvasWidth) { | 
| this.canvas = canvas; | 
| this.canvasCtx = canvas.getContext('2d'); | 
| -  this.image = spriteSheet; | 
| +  this.image = Runner.imageSprite; | 
| +  this.spritePos = spritePos; | 
| this.x = 0; | 
| this.y = 5; | 
|  | 
| @@ -1662,6 +1807,7 @@ function DistanceMeter(canvas, spriteSheet, canvasWidth) { | 
| this.flashIterations = 0; | 
|  | 
| this.config = DistanceMeter.config; | 
| +  this.maxScoreUnits = this.config.MAX_DISTANCE_UNITS; | 
| this.init(canvasWidth); | 
| }; | 
|  | 
| @@ -1679,7 +1825,7 @@ DistanceMeter.dimensions = { | 
| /** | 
| * Y positioning of the digits in the sprite sheet. | 
| * X position is always 0. | 
| - * @type {array<number>} | 
| + * @type {Array<number>} | 
| */ | 
| DistanceMeter.yPos = [0, 13, 27, 40, 53, 67, 80, 93, 107, 120]; | 
|  | 
| @@ -1715,8 +1861,8 @@ DistanceMeter.prototype = { | 
| var maxDistanceStr = ''; | 
|  | 
| this.calcXPos(width); | 
| -    this.maxScore = this.config.MAX_DISTANCE_UNITS; | 
| -    for (var i = 0; i < this.config.MAX_DISTANCE_UNITS; i++) { | 
| +    this.maxScore = this.maxScoreUnits; | 
| +    for (var i = 0; i < this.maxScoreUnits; i++) { | 
| this.draw(i, 0); | 
| this.defaultString += '0'; | 
| maxDistanceStr += '9'; | 
| @@ -1731,7 +1877,7 @@ DistanceMeter.prototype = { | 
| */ | 
| calcXPos: function(canvasWidth) { | 
| this.x = canvasWidth - (DistanceMeter.dimensions.DEST_WIDTH * | 
| -        (this.config.MAX_DISTANCE_UNITS + 1)); | 
| +        (this.maxScoreUnits + 1)); | 
| }, | 
|  | 
| /** | 
| @@ -1744,6 +1890,7 @@ DistanceMeter.prototype = { | 
| var sourceWidth = DistanceMeter.dimensions.WIDTH; | 
| var sourceHeight = DistanceMeter.dimensions.HEIGHT; | 
| var sourceX = DistanceMeter.dimensions.WIDTH * value; | 
| +    var sourceY = 0; | 
|  | 
| var targetX = digitPos * DistanceMeter.dimensions.DEST_WIDTH; | 
| var targetY = this.y; | 
| @@ -1757,18 +1904,21 @@ DistanceMeter.prototype = { | 
| sourceX *= 2; | 
| } | 
|  | 
| +    sourceX += this.spritePos.x; | 
| +    sourceY += this.spritePos.y; | 
| + | 
| this.canvasCtx.save(); | 
|  | 
| if (opt_highScore) { | 
| // Left of the current score. | 
| -      var highScoreX = this.x - (this.config.MAX_DISTANCE_UNITS * 2) * | 
| +      var highScoreX = this.x - (this.maxScoreUnits * 2) * | 
| DistanceMeter.dimensions.WIDTH; | 
| this.canvasCtx.translate(highScoreX, this.y); | 
| } else { | 
| this.canvasCtx.translate(this.x, this.y); | 
| } | 
|  | 
| -    this.canvasCtx.drawImage(this.image, sourceX, 0, | 
| +    this.canvasCtx.drawImage(this.image, sourceX, sourceY, | 
| sourceWidth, sourceHeight, | 
| targetX, targetY, | 
| targetWidth, targetHeight | 
| @@ -1783,14 +1933,13 @@ DistanceMeter.prototype = { | 
| * @return {number} The 'real' distance ran. | 
| */ | 
| getActualDistance: function(distance) { | 
| -    return distance ? | 
| -        Math.round(distance * this.config.COEFFICIENT) : 0; | 
| +    return distance ? Math.round(distance * this.config.COEFFICIENT) : 0; | 
| }, | 
|  | 
| /** | 
| * Update the distance meter. | 
| -   * @param {number} deltaTime | 
| * @param {number} distance | 
| +   * @param {number} deltaTime | 
| * @return {boolean} Whether the acheivement sound fx should be played. | 
| */ | 
| update: function(deltaTime, distance) { | 
| @@ -1800,6 +1949,15 @@ DistanceMeter.prototype = { | 
| if (!this.acheivement) { | 
| distance = this.getActualDistance(distance); | 
|  | 
| +      // Score has gone beyond the initial digit count. | 
| +      if (distance > this.maxScore && this.maxScoreUnits == | 
| +        this.config.MAX_DISTANCE_UNITS) { | 
| +        this.maxScoreUnits++; | 
| +        this.maxScore = parseInt(this.maxScore + '9'); | 
| +      } else { | 
| +        this.distance = 0; | 
| +      } | 
| + | 
| if (distance > 0) { | 
| // Acheivement unlocked | 
| if (distance % this.config.ACHIEVEMENT_DISTANCE == 0) { | 
| @@ -1811,7 +1969,7 @@ DistanceMeter.prototype = { | 
|  | 
| // Create a string representation of the distance with leading 0. | 
| var distanceStr = (this.defaultString + | 
| -            distance).substr(-this.config.MAX_DISTANCE_UNITS); | 
| +            distance).substr(-this.maxScoreUnits); | 
| this.digits = distanceStr.split(''); | 
| } else { | 
| this.digits = this.defaultString.split(''); | 
| @@ -1867,7 +2025,7 @@ DistanceMeter.prototype = { | 
| setHighScore: function(distance) { | 
| distance = this.getActualDistance(distance); | 
| var highScoreStr = (this.defaultString + | 
| -        distance).substr(-this.config.MAX_DISTANCE_UNITS); | 
| +        distance).substr(-this.maxScoreUnits); | 
|  | 
| this.highScore = ['10', '11', ''].concat(highScoreStr.split('')); | 
| }, | 
| @@ -1888,13 +2046,13 @@ DistanceMeter.prototype = { | 
| * Cloud background item. | 
| * Similar to an obstacle object but without collision boxes. | 
| * @param {HTMLCanvasElement} canvas Canvas element. | 
| - * @param {Image} cloudImg | 
| + * @param {Object} spritePos Position of image in sprite. | 
| * @param {number} containerWidth | 
| */ | 
| -function Cloud(canvas, cloudImg, containerWidth) { | 
| +function Cloud(canvas, spritePos, containerWidth) { | 
| this.canvas = canvas; | 
| this.canvasCtx = this.canvas.getContext('2d'); | 
| -  this.image = cloudImg; | 
| +  this.spritePos = spritePos; | 
| this.containerWidth = containerWidth; | 
| this.xPos = containerWidth; | 
| this.yPos = 0; | 
| @@ -1943,7 +2101,8 @@ Cloud.prototype = { | 
| sourceHeight = sourceHeight * 2; | 
| } | 
|  | 
| -    this.canvasCtx.drawImage(this.image, 0, 0, | 
| +    this.canvasCtx.drawImage(Runner.imageSprite, this.spritePos.x, | 
| +        this.spritePos.y, | 
| sourceWidth, sourceHeight, | 
| this.xPos, this.yPos, | 
| Cloud.config.WIDTH, Cloud.config.HEIGHT); | 
| @@ -1983,16 +2142,17 @@ Cloud.prototype = { | 
| * Horizon Line. | 
| * Consists of two connecting lines. Randomly assigns a flat / bumpy horizon. | 
| * @param {HTMLCanvasElement} canvas | 
| - * @param {HTMLImage} bgImg Horizon line sprite. | 
| + * @param {Object} spritePos Horizon position in sprite. | 
| * @constructor | 
| */ | 
| -function HorizonLine(canvas, bgImg) { | 
| -  this.image = bgImg; | 
| +function HorizonLine(canvas, spritePos) { | 
| +  this.spritePos = spritePos; | 
| this.canvas = canvas; | 
| this.canvasCtx = canvas.getContext('2d'); | 
| this.sourceDimensions = {}; | 
| this.dimensions = HorizonLine.dimensions; | 
| -  this.sourceXPos = [0, this.dimensions.WIDTH]; | 
| +  this.sourceXPos = [this.spritePos.x, this.spritePos.x + | 
| +      this.dimensions.WIDTH]; | 
| this.xPos = []; | 
| this.yPos = 0; | 
| this.bumpThreshold = 0.5; | 
| @@ -2047,12 +2207,14 @@ HorizonLine.prototype = { | 
| * Draw the horizon line. | 
| */ | 
| draw: function() { | 
| -    this.canvasCtx.drawImage(this.image, this.sourceXPos[0], 0, | 
| +    this.canvasCtx.drawImage(Runner.imageSprite, this.sourceXPos[0], | 
| +        this.spritePos.y, | 
| this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT, | 
| this.xPos[0], this.yPos, | 
| this.dimensions.WIDTH, this.dimensions.HEIGHT); | 
|  | 
| -    this.canvasCtx.drawImage(this.image, this.sourceXPos[1], 0, | 
| +    this.canvasCtx.drawImage(Runner.imageSprite, this.sourceXPos[1], | 
| +        this.spritePos.y, | 
| this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT, | 
| this.xPos[1], this.yPos, | 
| this.dimensions.WIDTH, this.dimensions.HEIGHT); | 
| @@ -2073,7 +2235,7 @@ HorizonLine.prototype = { | 
| if (this.xPos[line1] <= -this.dimensions.WIDTH) { | 
| this.xPos[line1] += this.dimensions.WIDTH * 2; | 
| this.xPos[line2] = this.xPos[line1] - this.dimensions.WIDTH; | 
| -      this.sourceXPos[line1] = this.getRandomType(); | 
| +      this.sourceXPos[line1] = this.getRandomType() + this.spritePos.x; | 
| } | 
| }, | 
|  | 
| @@ -2108,36 +2270,30 @@ HorizonLine.prototype = { | 
| /** | 
| * Horizon background class. | 
| * @param {HTMLCanvasElement} canvas | 
| - * @param {Array<HTMLImageElement>} images | 
| - * @param {object} dimensions Canvas dimensions. | 
| + * @param {Object} spritePos Sprite positioning. | 
| + * @param {Object} dimensions Canvas dimensions. | 
| * @param {number} gapCoefficient | 
| * @constructor | 
| */ | 
| -function Horizon(canvas, images, dimensions, gapCoefficient) { | 
| +function Horizon(canvas, spritePos, dimensions, gapCoefficient) { | 
| this.canvas = canvas; | 
| this.canvasCtx = this.canvas.getContext('2d'); | 
| this.config = Horizon.config; | 
| this.dimensions = dimensions; | 
| this.gapCoefficient = gapCoefficient; | 
| this.obstacles = []; | 
| +  this.obstacleHistory = []; | 
| this.horizonOffsets = [0, 0]; | 
| this.cloudFrequency = this.config.CLOUD_FREQUENCY; | 
| +  this.spritePos = spritePos; | 
|  | 
| // Cloud | 
| this.clouds = []; | 
| -  this.cloudImg = images.CLOUD; | 
| this.cloudSpeed = this.config.BG_CLOUD_SPEED; | 
|  | 
| // Horizon | 
| -  this.horizonImg = images.HORIZON; | 
| this.horizonLine = null; | 
|  | 
| -  // Obstacles | 
| -  this.obstacleImgs = { | 
| -    CACTUS_SMALL: images.CACTUS_SMALL, | 
| -    CACTUS_LARGE: images.CACTUS_LARGE | 
| -  }; | 
| - | 
| this.init(); | 
| }; | 
|  | 
| @@ -2161,7 +2317,7 @@ Horizon.prototype = { | 
| */ | 
| init: function() { | 
| this.addCloud(); | 
| -    this.horizonLine = new HorizonLine(this.canvas, this.horizonImg); | 
| +    this.horizonLine = new HorizonLine(this.canvas, this.spritePos.HORIZON); | 
| }, | 
|  | 
| /** | 
| @@ -2252,13 +2408,42 @@ Horizon.prototype = { | 
| * @param {number} currentSpeed | 
| */ | 
| addNewObstacle: function(currentSpeed) { | 
| -    var obstacleTypeIndex = | 
| -        getRandomNum(0, Obstacle.types.length - 1); | 
| +    var obstacleTypeIndex = getRandomNum(0, Obstacle.types.length - 1); | 
| var obstacleType = Obstacle.types[obstacleTypeIndex]; | 
| -    var obstacleImg = this.obstacleImgs[obstacleType.type]; | 
|  | 
| -    this.obstacles.push(new Obstacle(this.canvasCtx, obstacleType, | 
| -        obstacleImg, this.dimensions, this.gapCoefficient, currentSpeed)); | 
| +    // Check for multiples of the same type of obstacle. | 
| +    // Also check obstacle is available at current speed. | 
| +    if (this.duplicateObstacleCheck(obstacleType.type) || | 
| +        currentSpeed < obstacleType.minSpeed) { | 
| +      this.addNewObstacle(currentSpeed); | 
| +    } else { | 
| +      var obstacleSpritePos = this.spritePos[obstacleType.type]; | 
| + | 
| +      this.obstacles.push(new Obstacle(this.canvasCtx, obstacleType, | 
| +          obstacleSpritePos, this.dimensions, | 
| +          this.gapCoefficient, currentSpeed)); | 
| + | 
| +      this.obstacleHistory.unshift(obstacleType.type); | 
| + | 
| +      if (this.obstacleHistory.length > 1) { | 
| +        this.obstacleHistory.splice(Runner.config.MAX_OBSTACLE_DUPLICATION); | 
| +      } | 
| +    } | 
| +  }, | 
| + | 
| +  /** | 
| +   * Returns whether the previous two obstacles are the same as the next one. | 
| +   * Maximum duplication is set in config value MAX_OBSTACLE_DUPLICATION. | 
| +   * @return {boolean} | 
| +   */ | 
| +  duplicateObstacleCheck: function(nextObstacleType) { | 
| +    var duplicateCount = 0; | 
| + | 
| +    for (var i = 0; i < this.obstacleHistory.length; i++) { | 
| +      duplicateCount = this.obstacleHistory[i] == nextObstacleType ? | 
| +          duplicateCount + 1 : 0; | 
| +    } | 
| +    return duplicateCount >= Runner.config.MAX_OBSTACLE_DUPLICATION; | 
| }, | 
|  | 
| /** | 
| @@ -2284,7 +2469,7 @@ Horizon.prototype = { | 
| * Add a new cloud to the horizon. | 
| */ | 
| addCloud: function() { | 
| -    this.clouds.push(new Cloud(this.canvas, this.cloudImg, | 
| +    this.clouds.push(new Cloud(this.canvas, this.spritePos.CLOUD, | 
| this.dimensions.WIDTH)); | 
| } | 
| }; | 
|  |