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