From 3a400a12e9079490a50fd4e8a885f663e0a26739 Mon Sep 17 00:00:00 2001 From: Igor Stuev <108066576+isstuev@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:23:47 +0100 Subject: [PATCH] Games (#2338) * puzzle15 * capybara * add claim button (fake) * fix capy runner * add claim feature * remove puzle15 * hide game for tests * fixes --- configs/app/features/easterEggBadge.ts | 24 + configs/app/features/index.ts | 1 + configs/envs/.env.eth | 3 +- deploy/tools/envs-validator/schema.ts | 1 + docs/ENVS.md | 10 + nextjs/csp/policies/app.ts | 3 + public/static/capibara/capybaraSprite.png | Bin 0 -> 13928 bytes public/static/capibara/capybaraSpriteX2.png | Bin 0 -> 17659 bytes public/static/capibara/index.js | 2648 +++++++++++++++++ ui/games/CapybaraRunner.tsx | 56 + ui/pages/BlockCountdown.pw.tsx | 4 +- ui/pages/BlockCountdown.tsx | 9 +- ...t_long-period-until-the-block-mobile-1.png | Bin 38033 -> 38031 bytes ..._short-period-until-the-block-mobile-1.png | Bin 34587 -> 34583 bytes 14 files changed, 2755 insertions(+), 4 deletions(-) create mode 100644 configs/app/features/easterEggBadge.ts create mode 100644 public/static/capibara/capybaraSprite.png create mode 100644 public/static/capibara/capybaraSpriteX2.png create mode 100644 public/static/capibara/index.js create mode 100644 ui/games/CapybaraRunner.tsx diff --git a/configs/app/features/easterEggBadge.ts b/configs/app/features/easterEggBadge.ts new file mode 100644 index 0000000000..d8ce02926b --- /dev/null +++ b/configs/app/features/easterEggBadge.ts @@ -0,0 +1,24 @@ +import type { Feature } from './types'; + +import { getEnvValue } from '../utils'; + +const badgeClaimLink = getEnvValue('NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK'); + +const title = 'Easter egg badge'; + +const config: Feature<{ badgeClaimLink: string }> = (() => { + if (badgeClaimLink) { + return Object.freeze({ + title, + isEnabled: true, + badgeClaimLink, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index c5815c2330..2652c0b480 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -11,6 +11,7 @@ export { default as celo } from './celo'; export { default as csvExport } from './csvExport'; export { default as dataAvailability } from './dataAvailability'; export { default as deFiDropdown } from './deFiDropdown'; +export { default as easterEggBadge } from './easterEggBadge'; export { default as faultProofSystem } from './faultProofSystem'; export { default as gasTracker } from './gasTracker'; export { default as getGasButton } from './getGasButton'; diff --git a/configs/envs/.env.eth b/configs/envs/.env.eth index ebb31e36ea..215b86e5d1 100644 --- a/configs/envs/.env.eth +++ b/configs/envs/.env.eth @@ -66,4 +66,5 @@ NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com -NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address \ No newline at end of file +NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address +NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/hiddenBlockBadge \ No newline at end of file diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 072daff4a7..6ed8487d94 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -897,6 +897,7 @@ const schema = yup }), NEXT_PUBLIC_REWARDS_SERVICE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_XSTAR_SCORE_URL: yup.string().test(urlTest), + NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK: yup.string().test(urlTest), // 6. External services envs NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), diff --git a/docs/ENVS.md b/docs/ENVS.md index b2842c2e73..c96fd7a319 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -862,6 +862,16 @@ This feature enables Blockscout Merits program. It requires that the [My account | NEXT_PUBLIC_DEX_POOLS_ENABLED | `boolean` | Set to true to enable the feature | Required | - | `true` | v1.37.0+ | | NEXT_PUBLIC_CONTRACT_INFO_API_HOST | `string` | Contract Info API endpoint url | Required | - | `https://contracts-info.services.blockscout.com` | v1.0.x+ | +  + +### Badge claim link + +| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | +| --- | --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK | `string` | Provide to enable the easter egg badge feature | - | - | `https://example.com` | v1.37.0+ | + +  + ## External services configuration ### Google ReCaptcha diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts index 6f366d5ec2..2ba5f68f5d 100644 --- a/nextjs/csp/policies/app.ts +++ b/nextjs/csp/policies/app.ts @@ -72,6 +72,9 @@ export function app(): CspDev.DirectiveDescriptor { // hash of ColorModeScript: system + dark '\'sha256-e7MRMmTzLsLQvIy1iizO1lXf7VWYoQ6ysj5fuUzvRwE=\'', '\'sha256-9A7qFFHmxdWjZMQmfzYD2XWaNHLu1ZmQB0Ds4Go764k=\'', + + // CapybaraRunner + '\'sha256-5+YTmTcBwCYdJ8Jetbr6kyjGp0Ry/H7ptpoun6CrSwQ=\'', ], 'style-src': [ diff --git a/public/static/capibara/capybaraSprite.png b/public/static/capibara/capybaraSprite.png new file mode 100644 index 0000000000000000000000000000000000000000..a5d6a07ce08fbe68542018a0eb86b292375d1f84 GIT binary patch literal 13928 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 DrfJf@ literal 0 HcmV?d00001 diff --git a/public/static/capibara/capybaraSpriteX2.png b/public/static/capibara/capybaraSpriteX2.png new file mode 100644 index 0000000000000000000000000000000000000000..0455a2997ff85f92b88d11dc34f327bebd04837f GIT binary patch literal 17659 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 literal 0 HcmV?d00001 diff --git a/public/static/capibara/index.js b/public/static/capibara/index.js new file mode 100644 index 0000000000..00d5d13dc7 --- /dev/null +++ b/public/static/capibara/index.js @@ -0,0 +1,2648 @@ +// Copyright (c) 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// extract from chromium source code by @liuwayong +(function () { + 'use strict'; + /** + * T-Rex runner. + * @param {string} outerContainerId Outer containing element id. + * @param {Object} opt_config + * @constructor + * @export + */ + function Runner(outerContainerId, opt_config) { + // Singleton + if (Runner.instance_) { + return Runner.instance_; + } + Runner.instance_ = this; + + this.outerContainerEl = document.querySelector(outerContainerId); + this.containerEl = null; + this.snackbarEl = null; + this.detailsButton = this.outerContainerEl.querySelector('#details-button'); + + this.config = opt_config || Runner.config; + + this.dimensions = Runner.defaultDimensions; + + this.canvas = null; + this.canvasCtx = null; + + this.tRex = null; + + this.distanceMeter = null; + this.distanceRan = 0; + + this.highestScore = 0; + + this.time = 0; + this.runningTime = 0; + this.msPerFrame = 1000 / FPS; + this.currentSpeed = this.config.SPEED; + + this.obstacles = []; + + this.activated = false; // Whether the easter egg has been activated. + this.playing = false; // Whether the game is currently in play state. + this.crashed = false; + this.paused = false; + this.inverted = false; + this.invertTimer = 0; + this.resizeTimerId_ = null; + + this.playCount = 0; + + // Sound FX. + // this.audioBuffer = null; + // this.soundFx = {}; + + // Global web audio context for playing sounds. + this.audioContext = null; + + // Images. + this.images = {}; + this.imagesLoaded = 0; + + this.highScoreReached = false; // Add this new flag + + if (this.isDisabled()) { + this.setupDisabledRunner(); + } else { + this.loadImages(); + } + } + window['Runner'] = Runner; + + + /** + * Default game width. + * @const + */ + var DEFAULT_WIDTH = 600; + + /** + * Frames per second. + * @const + */ + var FPS = 60; + + /** @const */ + var IS_HIDPI = 2; + + /** @const */ + var IS_IOS = /iPad|iPhone|iPod/.test(window.navigator.platform); + + /** @const */ + var IS_MOBILE = /Android/.test(window.navigator.userAgent) || IS_IOS; + + /** @const */ + var IS_TOUCH_ENABLED = 'ontouchstart' in window; + + /** + * Default game configuration. + * @enum {number} + */ + Runner.config = { + ACCELERATION: 0.001, + BG_CLOUD_SPEED: 0.2, + BOTTOM_PAD: 10, + CLEAR_TIME: 3000, + CLOUD_FREQUENCY: 0.5, + GAMEOVER_CLEAR_TIME: 750, + GAP_COEFFICIENT: 0.6, + GRAVITY: 0.6, + INITIAL_JUMP_VELOCITY: 12, + INVERT_FADE_DURATION: 12000, + INVERT_DISTANCE: 700, + MAX_CLOUDS: 6, + MAX_OBSTACLE_LENGTH: 3, + MAX_OBSTACLE_DUPLICATION: 2, + MAX_SPEED: 13, + MIN_JUMP_HEIGHT: 35, + MOBILE_SPEED_COEFFICIENT: 1.2, + RESOURCE_TEMPLATE_ID: 'audio-resources', + SPEED: 6, + SPEED_DROP_COEFFICIENT: 3 + }; + + + /** + * Default dimensions. + * @enum {string} + */ + Runner.defaultDimensions = { + WIDTH: DEFAULT_WIDTH, + HEIGHT: 150 + }; + + + /** + * CSS class names. + * @enum {string} + */ + Runner.classes = { + CANVAS: 'runner-canvas', + CONTAINER: 'runner-container', + CRASHED: 'crashed', + INVERTED: 'inverted', + SNACKBAR: 'snackbar', + SNACKBAR_SHOW: 'snackbar-show', + TOUCH_CONTROLLER: 'controller' + }; + + + /** + * Sprite definition layout of the spritesheet. + * @enum {Object} + */ + 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 }, + MOON: { x: 484, y: 2 }, + RESTART: { x: 2, y: 2 }, + TEXT_SPRITE: { x: 655, y: 2 }, + TREX: { x: 848, y: 2 }, + STAR: { x: 645, 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 }, + MOON: { x: 954, y: 2 }, + RESTART: { x: 2, y: 2 }, + TEXT_SPRITE: { x: 1292, y: 2 }, + TREX: { x: 1678, y: 2 }, + STAR: { x: 1276, y: 2 } + } + }; + + + /** + * Key code mapping. + * @enum {Object} + */ + Runner.keycodes = { + JUMP: { '38': 1, '32': 1 }, // Up, spacebar + DUCK: { '40': 1 }, // Down + RESTART: { '13': 1 } // Enter + }; + + + /** + * Runner event names. + * @enum {string} + */ + Runner.events = { + ANIM_END: 'webkitAnimationEnd', + CLICK: 'click', + KEYDOWN: 'keydown', + KEYUP: 'keyup', + MOUSEDOWN: 'mousedown', + MOUSEUP: 'mouseup', + RESIZE: 'resize', + TOUCHEND: 'touchend', + TOUCHSTART: 'touchstart', + VISIBILITY: 'visibilitychange', + BLUR: 'blur', + FOCUS: 'focus', + LOAD: 'load' + }; + + + Runner.prototype = { + /** + * Whether the easter egg has been disabled. CrOS enterprise enrolled devices. + * @return {boolean} + */ + isDisabled: function () { + // return loadTimeData && loadTimeData.valueExists('disabledEasterEgg'); + return false; + }, + + /** + * For disabled instances, set up a snackbar with the disabled message. + */ + setupDisabledRunner: function () { + this.containerEl = document.createElement('div'); + this.containerEl.className = Runner.classes.SNACKBAR; + this.containerEl.textContent = loadTimeData.getValue('disabledEasterEgg'); + this.outerContainerEl.appendChild(this.containerEl); + + // Show notification when the activation key is pressed. + document.addEventListener(Runner.events.KEYDOWN, function (e) { + if (Runner.keycodes.JUMP[e.keyCode]) { + this.containerEl.classList.add(Runner.classes.SNACKBAR_SHOW); + document.querySelector('.icon').classList.add('icon-disabled'); + } + }.bind(this)); + }, + + /** + * Setting individual settings for debugging. + * @param {string} setting + * @param {*} value + */ + updateConfigSetting: function (setting, value) { + if (setting in this.config && value != undefined) { + this.config[setting] = value; + + switch (setting) { + case 'GRAVITY': + case 'MIN_JUMP_HEIGHT': + case 'SPEED_DROP_COEFFICIENT': + this.tRex.config[setting] = value; + break; + case 'INITIAL_JUMP_VELOCITY': + this.tRex.setJumpVelocity(value); + break; + case 'SPEED': + this.setSpeed(value); + break; + } + } + }, + + /** + * Cache the appropriate image sprite from the page and get the sprite sheet + * definition. + */ + loadImages: function () { + if (IS_HIDPI) { + Runner.imageSprite = document.getElementById('offline-resources-2x'); + this.spriteDef = Runner.spriteDefinition.HDPI; + } else { + Runner.imageSprite = document.getElementById('offline-resources-2x'); + this.spriteDef = Runner.spriteDefinition.LDPI; + } + + if (Runner.imageSprite.complete) { + this.init(); + } else { + // If the images are not yet loaded, add a listener. + Runner.imageSprite.addEventListener(Runner.events.LOAD, + this.init.bind(this)); + } + }, + + /** + * Load and decode base 64 encoded sounds. + // */ + // loadSounds: function () { + // if (!IS_IOS) { + // this.audioContext = new AudioContext(); + + // var resourceTemplate = + // document.getElementById(this.config.RESOURCE_TEMPLATE_ID).content; + + // for (var sound in Runner.sounds) { + // var soundSrc = + // resourceTemplate.getElementById(Runner.sounds[sound]).src; + // soundSrc = soundSrc.substr(soundSrc.indexOf(',') + 1); + // var buffer = decodeBase64ToArrayBuffer(soundSrc); + + // // Async, so no guarantee of order in array. + // this.audioContext.decodeAudioData(buffer, function (index, audioData) { + // this.soundFx[index] = audioData; + // }.bind(this, sound)); + // } + // } + // }, + + /** + * Sets the game speed. Adjust the speed accordingly if on a smaller screen. + * @param {number} opt_speed + */ + setSpeed: function (opt_speed) { + var speed = opt_speed || this.currentSpeed; + + // Reduce the speed on smaller mobile screens. + if (this.dimensions.WIDTH < DEFAULT_WIDTH) { + var mobileSpeed = speed * this.dimensions.WIDTH / DEFAULT_WIDTH * + this.config.MOBILE_SPEED_COEFFICIENT; + this.currentSpeed = mobileSpeed > speed ? speed : mobileSpeed; + } else if (opt_speed) { + this.currentSpeed = opt_speed; + } + }, + + /** + * Game initialiser. + */ + init: function () { + + this.adjustDimensions(); + this.setSpeed(); + + this.containerEl = document.createElement('div'); + this.containerEl.className = Runner.classes.CONTAINER; + + // Player canvas container. + this.canvas = createCanvas(this.containerEl, this.dimensions.WIDTH, + this.dimensions.HEIGHT, Runner.classes.PLAYER); + + this.canvasCtx = this.canvas.getContext('2d'); + this.canvasCtx.fillStyle = '#f7f7f7'; + this.canvasCtx.fill(); + Runner.updateCanvasScaling(this.canvas); + + // Horizon contains clouds, obstacles and the ground. + this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, + this.config.GAP_COEFFICIENT); + + // Distance meter + this.distanceMeter = new DistanceMeter(this.canvas, + this.spriteDef.TEXT_SPRITE, this.dimensions.WIDTH); + + // Draw t-rex + this.tRex = new Trex(this.canvas, this.spriteDef.TREX); + + this.outerContainerEl.appendChild(this.containerEl); + + if (IS_MOBILE) { + this.createTouchController(); + } + + this.startListening(); + this.update(); + + window.addEventListener(Runner.events.RESIZE, + this.debounceResize.bind(this)); + }, + + /** + * Create the touch controller. A div that covers whole screen. + */ + createTouchController: function () { + this.touchController = document.createElement('div'); + this.touchController.className = Runner.classes.TOUCH_CONTROLLER; + this.outerContainerEl.appendChild(this.touchController); + }, + + /** + * Debounce the resize event. + */ + debounceResize: function () { + if (!this.resizeTimerId_) { + this.resizeTimerId_ = + setInterval(this.adjustDimensions.bind(this), 250); + } + }, + + /** + * Adjust game space dimensions on resize. + */ + adjustDimensions: function () { + clearInterval(this.resizeTimerId_); + this.resizeTimerId_ = null; + + var boxStyles = window.getComputedStyle(this.outerContainerEl); + var padding = Number(boxStyles.paddingLeft.substr(0, + boxStyles.paddingLeft.length - 2)); + + this.dimensions.WIDTH = this.outerContainerEl.offsetWidth - padding * 2; + + // Redraw the elements back onto the canvas. + if (this.canvas) { + this.canvas.width = this.dimensions.WIDTH; + this.canvas.height = this.dimensions.HEIGHT; + + Runner.updateCanvasScaling(this.canvas); + + this.distanceMeter.calcXPos(this.dimensions.WIDTH); + this.clearCanvas(); + this.horizon.update(0, 0, true); + this.tRex.update(0); + + // Outer container and distance meter. + if (this.playing || this.crashed || this.paused) { + this.containerEl.style.width = this.dimensions.WIDTH + 'px'; + this.containerEl.style.height = this.dimensions.HEIGHT + 'px'; + this.distanceMeter.update(0, Math.ceil(this.distanceRan)); + this.stop(); + } else { + this.tRex.draw(0, 0); + } + + // Game over panel. + if (this.crashed && this.gameOverPanel) { + this.gameOverPanel.updateDimensions(this.dimensions.WIDTH); + this.gameOverPanel.draw(); + } + } + }, + + /** + * Play the game intro. + * Canvas container width expands out to the full width. + */ + playIntro: function () { + if (!this.activated && !this.crashed) { + this.playingIntro = true; + this.tRex.playingIntro = true; + + // CSS animation definition. + var keyframes = '@-webkit-keyframes intro { ' + + 'from { width:' + Trex.config.WIDTH + 'px }' + + 'to { width: ' + this.dimensions.WIDTH + 'px }' + + '}'; + + // create a style sheet to put the keyframe rule in + // and then place the style sheet in the html head + var sheet = document.createElement('style'); + sheet.innerHTML = keyframes; + document.head.appendChild(sheet); + + this.containerEl.addEventListener(Runner.events.ANIM_END, + this.startGame.bind(this)); + + this.containerEl.style.webkitAnimation = 'intro .4s ease-out 1 both'; + this.containerEl.style.width = this.dimensions.WIDTH + 'px'; + + // if (this.touchController) { + // this.outerContainerEl.appendChild(this.touchController); + // } + this.playing = true; + this.activated = true; + } else if (this.crashed) { + this.restart(); + } + }, + + + /** + * Update the game status to started. + */ + startGame: function () { + this.runningTime = 0; + this.playingIntro = false; + this.tRex.playingIntro = false; + this.containerEl.style.webkitAnimation = ''; + this.playCount++; + + // Handle tabbing off the page. Pause the current game. + document.addEventListener(Runner.events.VISIBILITY, + this.onVisibilityChange.bind(this)); + + window.addEventListener(Runner.events.BLUR, + this.onVisibilityChange.bind(this)); + + window.addEventListener(Runner.events.FOCUS, + this.onVisibilityChange.bind(this)); + }, + + clearCanvas: function () { + this.canvasCtx.clearRect(0, 0, this.dimensions.WIDTH, + this.dimensions.HEIGHT); + }, + + /** + * Update the game frame and schedules the next one. + */ + update: function () { + this.updatePending = false; + + var now = getTimeStamp(); + var deltaTime = now - (this.time || now); + this.time = now; + + if (this.playing) { + this.clearCanvas(); + + if (this.tRex.jumping) { + this.tRex.updateJump(deltaTime); + } + + this.runningTime += deltaTime; + var hasObstacles = this.runningTime > this.config.CLEAR_TIME; + + // First jump triggers the intro. + if (this.tRex.jumpCount == 1 && !this.playingIntro) { + this.playIntro(); + } + + // The horizon doesn't move until the intro is over. + if (this.playingIntro) { + this.horizon.update(0, this.currentSpeed, hasObstacles); + } else { + deltaTime = !this.activated ? 0 : deltaTime; + this.horizon.update(deltaTime, this.currentSpeed, hasObstacles, + this.inverted); + } + + // Check for collisions. + var collision = hasObstacles && + checkForCollision(this.horizon.obstacles[0], this.tRex); + + if (!collision) { + this.distanceRan += this.currentSpeed * deltaTime / this.msPerFrame; + + if (!this.highScoreReached && this.distanceMeter.getActualDistance(this.distanceRan) >= 1000) { + this.highScoreReached = true; + window.dispatchEvent(new CustomEvent('reachedHighScore', { + detail: { score: this.distanceRan } + })); + } + + if (this.currentSpeed < this.config.MAX_SPEED) { + this.currentSpeed += this.config.ACCELERATION; + } + } else { + this.gameOver(); + } + + this.distanceMeter.update(deltaTime, + Math.ceil(this.distanceRan)); + + // Night mode. + if (this.invertTimer > this.config.INVERT_FADE_DURATION) { + this.invertTimer = 0; + this.invertTrigger = false; + this.invert(); + } else if (this.invertTimer) { + this.invertTimer += deltaTime; + } else { + var actualDistance = + this.distanceMeter.getActualDistance(Math.ceil(this.distanceRan)); + + if (actualDistance > 0) { + this.invertTrigger = !(actualDistance % + this.config.INVERT_DISTANCE); + + if (this.invertTrigger && this.invertTimer === 0) { + this.invertTimer += deltaTime; + this.invert(); + } + } + } + } + + if (this.playing || !this.activated) { + this.tRex.update(deltaTime); + this.scheduleNextUpdate(); + } + }, + + /** + * Event handler. + */ + handleEvent: function (e) { + return (function (evtType, events) { + switch (evtType) { + case events.KEYDOWN: + case events.TOUCHSTART: + case events.MOUSEDOWN: + this.onKeyDown(e); + break; + case events.KEYUP: + case events.TOUCHEND: + case events.MOUSEUP: + this.onKeyUp(e); + break; + } + }.bind(this))(e.type, Runner.events); + }, + + /** + * Bind relevant key / mouse / touch listeners. + */ + startListening: function () { + // Keys. + document.addEventListener(Runner.events.KEYDOWN, this); + document.addEventListener(Runner.events.KEYUP, this); + + if (IS_MOBILE) { + // Mobile only touch devices. + this.touchController.addEventListener(Runner.events.TOUCHSTART, this); + this.touchController.addEventListener(Runner.events.TOUCHEND, this); + this.containerEl.addEventListener(Runner.events.TOUCHSTART, this); + } else { + // Mouse. + document.addEventListener(Runner.events.MOUSEDOWN, this); + document.addEventListener(Runner.events.MOUSEUP, this); + } + }, + + /** + * Remove all listeners. + */ + stopListening: function () { + document.removeEventListener(Runner.events.KEYDOWN, this); + document.removeEventListener(Runner.events.KEYUP, this); + + if (IS_MOBILE) { + this.touchController.removeEventListener(Runner.events.TOUCHSTART, this); + this.touchController.removeEventListener(Runner.events.TOUCHEND, this); + this.containerEl.removeEventListener(Runner.events.TOUCHSTART, this); + } else { + document.removeEventListener(Runner.events.MOUSEDOWN, this); + document.removeEventListener(Runner.events.MOUSEUP, this); + } + }, + + /** + * Process keydown. + * @param {Event} e + */ + onKeyDown: function (e) { + // Prevent native page scrolling whilst tapping on mobile. + if (this.playing) { + e.preventDefault(); + } + + if (e.target != this.detailsButton) { + if (!this.crashed && (Runner.keycodes.JUMP[e.keyCode] || + e.type == Runner.events.TOUCHSTART)) { + if (!this.playing) { + // this.loadSounds(); + this.playing = true; + this.update(); + if (window.errorPageController) { + errorPageController.trackEasterEgg(); + } + } + // Play sound effect and jump on starting the game for the first time. + if (!this.tRex.jumping && !this.tRex.ducking) { + // this.playSound(this.soundFx.BUTTON_PRESS); + this.tRex.startJump(this.currentSpeed); + } + } + + if (this.crashed && e.type == Runner.events.TOUCHSTART && + e.currentTarget == this.containerEl) { + this.restart(); + } + } + + if (this.playing && !this.crashed && Runner.keycodes.DUCK[e.keyCode]) { + e.preventDefault(); + if (this.tRex.jumping) { + // Speed drop, activated only when jump key is not pressed. + this.tRex.setSpeedDrop(); + } + } + }, + + + /** + * Process key up. + * @param {Event} e + */ + onKeyUp: function (e) { + var keyCode = String(e.keyCode); + var isjumpKey = Runner.keycodes.JUMP[keyCode] || + e.type == Runner.events.TOUCHEND || + e.type == Runner.events.MOUSEDOWN; + + if (this.isRunning() && isjumpKey) { + this.tRex.endJump(); + } else if (Runner.keycodes.DUCK[keyCode]) { + this.tRex.speedDrop = 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] || 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 != null && e.button < 2 && + e.type == Runner.events.MOUSEUP && e.target == this.canvas; + }, + + /** + * RequestAnimationFrame wrapper. + */ + scheduleNextUpdate: function () { + if (!this.updatePending) { + this.updatePending = true; + this.raqId = requestAnimationFrame(this.update.bind(this)); + } + }, + + /** + * Whether the game is running. + * @return {boolean} + */ + isRunning: function () { + return !!this.raqId; + }, + + /** + * Game over state. + */ + gameOver: function () { + // this.playSound(this.soundFx.HIT); + vibrate(200); + + this.stop(); + this.crashed = true; + this.distanceMeter.acheivement = false; + + this.tRex.update(100, Trex.status.CRASHED); + + // Game over panel. + if (!this.gameOverPanel) { + this.gameOverPanel = new GameOverPanel(this.canvas, + this.spriteDef.TEXT_SPRITE, this.spriteDef.RESTART, + this.dimensions); + } else { + this.gameOverPanel.draw(); + } + + // Update the high score. + if (this.distanceRan > this.highestScore) { + this.highestScore = Math.ceil(this.distanceRan); + this.distanceMeter.setHighScore(this.highestScore); + } + + // Reset the time clock. + this.time = getTimeStamp(); + }, + + stop: function () { + this.playing = false; + this.paused = true; + cancelAnimationFrame(this.raqId); + this.raqId = 0; + }, + + play: function () { + if (!this.crashed) { + this.playing = true; + this.paused = false; + this.tRex.update(0, Trex.status.RUNNING); + this.time = getTimeStamp(); + this.update(); + } + }, + + restart: function () { + if (!this.raqId) { + this.playCount++; + this.runningTime = 0; + this.playing = true; + this.crashed = false; + this.distanceRan = 0; + this.setSpeed(this.config.SPEED); + this.time = getTimeStamp(); + this.containerEl.classList.remove(Runner.classes.CRASHED); + this.clearCanvas(); + this.distanceMeter.reset(this.highestScore); + this.horizon.reset(); + this.tRex.reset(); + // this.playSound(this.soundFx.BUTTON_PRESS); + this.invert(true); + this.update(); + } + }, + + /** + * Pause the game if the tab is not in focus. + */ + onVisibilityChange: function (e) { + if (document.hidden || document.webkitHidden || e.type == 'blur' || + document.visibilityState != 'visible') { + this.stop(); + } else if (!this.crashed) { + this.tRex.reset(); + this.play(); + } + }, + + // /** + // * Play a sound. + // * @param {SoundBuffer} soundBuffer + // */ + // playSound: function (soundBuffer) { + // if (soundBuffer) { + // var sourceNode = this.audioContext.createBufferSource(); + // sourceNode.buffer = soundBuffer; + // sourceNode.connect(this.audioContext.destination); + // sourceNode.start(0); + // } + // }, + + /** + * Inverts the current page / canvas colors. + * @param {boolean} Whether to reset colors. + */ + invert: function (reset) { + if (reset) { + document.body.classList.toggle(Runner.classes.INVERTED, false); + this.invertTimer = 0; + this.inverted = false; + } else { + this.inverted = document.body.classList.toggle(Runner.classes.INVERTED, + this.invertTrigger); + } + } + }; + + + /** + * Updates the canvas size taking into + * account the backing store pixel ratio and + * the device pixel ratio. + * + * See article by Paul Lewis: + * http://www.html5rocks.com/en/tutorials/canvas/hidpi/ + * + * @param {HTMLCanvasElement} canvas + * @param {number} opt_width + * @param {number} opt_height + * @return {boolean} Whether the canvas was scaled. + */ + Runner.updateCanvasScaling = function (canvas, opt_width, opt_height) { + var context = canvas.getContext('2d'); + + // Query the various pixel ratios + var devicePixelRatio = Math.floor(window.devicePixelRatio) || 1; + var backingStoreRatio = Math.floor(context.webkitBackingStorePixelRatio) || 1; + var ratio = devicePixelRatio / backingStoreRatio; + + // 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; + + canvas.width = oldWidth * ratio; + canvas.height = oldHeight * ratio; + + canvas.style.width = oldWidth + 'px'; + canvas.style.height = oldHeight + 'px'; + + // Scale the context to counter the fact that we've manually scaled + // our canvas element. + context.scale(ratio, ratio); + return true; + } else if (devicePixelRatio == 1) { + // Reset the canvas width / height. Fixes scaling bug when the page is + // zoomed and the devicePixelRatio changes accordingly. + canvas.style.width = canvas.width + 'px'; + canvas.style.height = canvas.height + 'px'; + } + return false; + }; + + + /** + * Get random number. + * @param {number} min + * @param {number} max + * @param {number} + */ + function getRandomNum(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + + /** + * Vibrate on mobile devices. + * @param {number} duration Duration of the vibration in milliseconds. + */ + function vibrate(duration) { + if (IS_MOBILE && window.navigator.vibrate) { + window.navigator.vibrate(duration); + } + } + + + /** + * Create canvas element. + * @param {HTMLElement} container Element to append canvas to. + * @param {number} width + * @param {number} height + * @param {string} opt_classname + * @return {HTMLCanvasElement} + */ + function createCanvas(container, width, height, opt_classname) { + var canvas = document.createElement('canvas'); + canvas.className = opt_classname ? Runner.classes.CANVAS + ' ' + + opt_classname : Runner.classes.CANVAS; + canvas.width = width; + canvas.height = height; + container.appendChild(canvas); + + return canvas; + } + + + /** + * Decodes the base 64 audio to ArrayBuffer used by Web Audio. + * @param {string} base64String + */ + function decodeBase64ToArrayBuffer(base64String) { + var len = (base64String.length / 4) * 3; + var str = atob(base64String); + var arrayBuffer = new ArrayBuffer(len); + var bytes = new Uint8Array(arrayBuffer); + + for (var i = 0; i < len; i++) { + bytes[i] = str.charCodeAt(i); + } + return bytes.buffer; + } + + + /** + * Return the current timestamp. + * @return {number} + */ + function getTimeStamp() { + return IS_IOS ? new Date().getTime() : performance.now(); + } + + + //****************************************************************************** + + + /** + * Game over panel. + * @param {!HTMLCanvasElement} canvas + * @param {Object} textImgPos + * @param {Object} restartImgPos + * @param {!Object} dimensions Canvas dimensions. + * @constructor + */ + function GameOverPanel(canvas, textImgPos, restartImgPos, dimensions) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.canvasDimensions = dimensions; + this.textImgPos = textImgPos; + this.restartImgPos = restartImgPos; + this.draw(); + }; + + + /** + * Dimensions used in the panel. + * @enum {number} + */ + GameOverPanel.dimensions = { + TEXT_X: 0, + TEXT_Y: 13, + TEXT_WIDTH: 191, + TEXT_HEIGHT: 11, + RESTART_WIDTH: 36, + RESTART_HEIGHT: 32 + }; + + + GameOverPanel.prototype = { + /** + * Update the panel dimensions. + * @param {number} width New canvas width. + * @param {number} opt_height Optional new canvas height. + */ + updateDimensions: function (width, opt_height) { + this.canvasDimensions.WIDTH = width; + if (opt_height) { + this.canvasDimensions.HEIGHT = opt_height; + } + }, + + /** + * Draw the panel. + */ + draw: function () { + var dimensions = GameOverPanel.dimensions; + + var centerX = this.canvasDimensions.WIDTH / 2; + + // Game over text. + var textSourceX = dimensions.TEXT_X; + var textSourceY = dimensions.TEXT_Y; + var textSourceWidth = dimensions.TEXT_WIDTH; + var textSourceHeight = dimensions.TEXT_HEIGHT; + + var textTargetX = Math.round(centerX - (dimensions.TEXT_WIDTH / 2)); + var textTargetY = Math.round((this.canvasDimensions.HEIGHT - 25) / 3); + var textTargetWidth = dimensions.TEXT_WIDTH; + var textTargetHeight = dimensions.TEXT_HEIGHT; + + var restartSourceWidth = dimensions.RESTART_WIDTH; + var restartSourceHeight = dimensions.RESTART_HEIGHT; + var restartTargetX = centerX - (dimensions.RESTART_WIDTH / 2); + var restartTargetY = this.canvasDimensions.HEIGHT / 2; + + if (IS_HIDPI) { + textSourceY *= 2; + textSourceX *= 2; + textSourceWidth *= 2; + textSourceHeight *= 2; + restartSourceWidth *= 2; + restartSourceHeight *= 2; + } + + textSourceX += this.textImgPos.x; + textSourceY += this.textImgPos.y; + + // Game over text from sprite. + this.canvasCtx.drawImage(Runner.imageSprite, + textSourceX, textSourceY, textSourceWidth, textSourceHeight, + textTargetX, textTargetY, textTargetWidth, textTargetHeight); + + // Restart button. + this.canvasCtx.drawImage(Runner.imageSprite, + this.restartImgPos.x, this.restartImgPos.y, + restartSourceWidth, restartSourceHeight, + restartTargetX, restartTargetY, dimensions.RESTART_WIDTH, + dimensions.RESTART_HEIGHT); + } + }; + + + //****************************************************************************** + + /** + * Check for a collision. + * @param {!Obstacle} obstacle + * @param {!Trex} tRex T-rex object. + * @param {HTMLCanvasContext} opt_canvasCtx Optional canvas context for drawing + * collision boxes. + * @return {Array} + */ + function checkForCollision(obstacle, tRex, opt_canvasCtx) { + var obstacleBoxXPos = Runner.defaultDimensions.WIDTH + obstacle.xPos; + + // Adjustments are made to the bounding box as there is a 1 pixel white + // border around the t-rex and obstacles. + var tRexBox = new CollisionBox( + tRex.xPos + 1, + tRex.yPos + 1, + tRex.config.WIDTH - 2, + tRex.config.HEIGHT - 2); + + var obstacleBox = new CollisionBox( + obstacle.xPos + 1, + obstacle.yPos + 1, + obstacle.typeConfig.width * obstacle.size - 2, + obstacle.typeConfig.height - 2); + + // Debug outer box + if (opt_canvasCtx) { + drawCollisionBoxes(opt_canvasCtx, tRexBox, obstacleBox); + } + + // Simple outer bounds check. + if (boxCompare(tRexBox, obstacleBox)) { + var collisionBoxes = obstacle.collisionBoxes; + var tRexCollisionBoxes = Trex.collisionBoxes.RUNNING; + + // Detailed axis aligned box check. + for (var t = 0; t < tRexCollisionBoxes.length; t++) { + for (var i = 0; i < collisionBoxes.length; i++) { + // Adjust the box to actual positions. + var adjTrexBox = + createAdjustedCollisionBox(tRexCollisionBoxes[t], tRexBox); + var adjObstacleBox = + createAdjustedCollisionBox(collisionBoxes[i], obstacleBox); + var crashed = boxCompare(adjTrexBox, adjObstacleBox); + + // Draw boxes for debug. + if (opt_canvasCtx) { + drawCollisionBoxes(opt_canvasCtx, adjTrexBox, adjObstacleBox); + } + + if (crashed) { + return [adjTrexBox, adjObstacleBox]; + } + } + } + } + return false; + }; + + + /** + * Adjust the collision box. + * @param {!CollisionBox} box The original box. + * @param {!CollisionBox} adjustment Adjustment box. + * @return {CollisionBox} The adjusted collision box object. + */ + function createAdjustedCollisionBox(box, adjustment) { + return new CollisionBox( + box.x + adjustment.x, + box.y + adjustment.y, + box.width, + box.height); + }; + + + /** + * Draw the collision boxes for debug. + */ + function drawCollisionBoxes(canvasCtx, tRexBox, obstacleBox) { + canvasCtx.save(); + canvasCtx.strokeStyle = '#f00'; + canvasCtx.strokeRect(tRexBox.x, tRexBox.y, tRexBox.width, tRexBox.height); + + canvasCtx.strokeStyle = '#0f0'; + canvasCtx.strokeRect(obstacleBox.x, obstacleBox.y, + obstacleBox.width, obstacleBox.height); + canvasCtx.restore(); + }; + + + /** + * Compare two collision boxes for a collision. + * @param {CollisionBox} tRexBox + * @param {CollisionBox} obstacleBox + * @return {boolean} Whether the boxes intersected. + */ + function boxCompare(tRexBox, obstacleBox) { + var crashed = false; + var tRexBoxX = tRexBox.x; + var tRexBoxY = tRexBox.y; + + var obstacleBoxX = obstacleBox.x; + var obstacleBoxY = obstacleBox.y; + + // Axis-Aligned Bounding Box method. + if (tRexBox.x < obstacleBoxX + obstacleBox.width && + tRexBox.x + tRexBox.width > obstacleBoxX && + tRexBox.y < obstacleBox.y + obstacleBox.height && + tRexBox.height + tRexBox.y > obstacleBox.y) { + crashed = true; + } + + return crashed; + }; + + + //****************************************************************************** + + /** + * Collision box object. + * @param {number} x X position. + * @param {number} y Y Position. + * @param {number} w Width. + * @param {number} h Height. + */ + function CollisionBox(x, y, w, h) { + this.x = x; + this.y = y; + this.width = w; + this.height = h; + }; + + + //****************************************************************************** + + /** + * Obstacle. + * @param {HTMLCanvasCtx} canvasCtx + * @param {Obstacle.type} type + * @param {Object} spritePos Obstacle position in sprite. + * @param {Object} dimensions + * @param {number} gapCoefficient Mutipler in determining the gap. + * @param {number} speed + * @param {number} opt_xOffset + */ + function Obstacle(canvasCtx, type, spriteImgPos, dimensions, + gapCoefficient, speed, opt_xOffset) { + + this.canvasCtx = canvasCtx; + 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 = dimensions.WIDTH + (opt_xOffset || 0); + 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); + }; + + /** + * Coefficient for calculating the maximum gap. + * @const + */ + Obstacle.MAX_GAP_COEFFICIENT = 1.5; + + /** + * Maximum obstacle grouping count. + * @const + */ + Obstacle.MAX_OBSTACLE_LENGTH = 3, + + + Obstacle.prototype = { + /** + * Initialise the DOM for the obstacle. + * @param {number} speed + */ + init: function (speed) { + this.cloneCollisionBoxes(); + + // Only allow sizing if we're at the right speed. + if (this.size > 1 && this.typeConfig.multipleSpeed > speed) { + this.size = 1; + } + + this.width = this.typeConfig.width * this.size; + + // 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, + // Central box is adjusted to the size as one box. + // ____ ______ ________ + // _| |-| _| |-| _| |-| + // | |<->| | | |<--->| | | |<----->| | + // | | 1 | | | | 2 | | | | 3 | | + // |_|___|_| |_|_____|_| |_|_______|_| + // + if (this.size > 1) { + this.collisionBoxes[1].width = this.width - this.collisionBoxes[0].width - + this.collisionBoxes[2].width; + 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); + }, + + /** + * Draw and crop based on size. + */ + draw: function () { + var sourceWidth = this.typeConfig.width; + var sourceHeight = this.typeConfig.height; + + if (IS_HIDPI) { + sourceWidth = sourceWidth * 2; + sourceHeight = sourceHeight * 2; + } + + // 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); + }, + + /** + * Obstacle frame update. + * @param {number} deltaTime + * @param {number} speed + */ + 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()) { + this.remove = true; + } + } + }, + + /** + * Calculate a random gap size. + * - Minimum gap gets wider as speed increses + * @param {number} gapCoefficient + * @param {number} speed + * @return {number} The gap size. + */ + getGap: function (gapCoefficient, speed) { + var minGap = Math.round(this.width * speed + + this.typeConfig.minGap * gapCoefficient); + var maxGap = Math.round(minGap * Obstacle.MAX_GAP_COEFFICIENT); + return getRandomNum(minGap, maxGap); + }, + + /** + * Check if obstacle is visible. + * @return {boolean} Whether the obstacle is in the game area. + */ + isVisible: function () { + return this.xPos + this.width > 0; + }, + + /** + * Make a copy of the collision boxes, since these will change based on + * obstacle type and size. + */ + cloneCollisionBoxes: function () { + var collisionBoxes = this.typeConfig.collisionBoxes; + + for (var i = collisionBoxes.length - 1; i >= 0; i--) { + this.collisionBoxes[i] = new CollisionBox(collisionBoxes[i].x, + collisionBoxes[i].y, collisionBoxes[i].width, + collisionBoxes[i].height); + } + } + }; + + + /** + * 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', + width: 17, + height: 35, + yPos: 105, + multipleSpeed: 4, + minGap: 120, + minSpeed: 0, + collisionBoxes: [ + new CollisionBox(0, 7, 5, 27), + new CollisionBox(4, 0, 6, 34), + new CollisionBox(10, 4, 7, 14) + ] + }, + { + type: 'CACTUS_LARGE', + width: 25, + height: 50, + yPos: 90, + 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) + ] + }, + ]; + + + //****************************************************************************** + /** + * T-rex game character. + * @param {HTMLCanvas} canvas + * @param {Object} spritePos Positioning within image sprite. + * @constructor + */ + function Trex(canvas, spritePos) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.spritePos = spritePos; + this.xPos = 0; + this.yPos = 0; + // Position when on the ground. + this.groundYPos = 0; + this.currentFrame = 0; + this.currentAnimFrames = []; + this.blinkDelay = 0; + this.blinkCount = 0; + this.animStartTime = 0; + this.timer = 0; + this.msPerFrame = 1000 / FPS; + this.config = Trex.config; + // Current status. + this.status = Trex.status.WAITING; + + this.jumping = false; + this.jumpVelocity = 0; + this.reachedMinHeight = false; + this.speedDrop = false; + this.jumpCount = 0; + this.jumpspotX = 0; + + this.init(); + }; + + + /** + * T-rex player config. + * @enum {number} + */ + Trex.config = { + DROP_VELOCITY: -5, + GRAVITY: 0.6, + HEIGHT: 47, + INIITAL_JUMP_VELOCITY: -10, + INTRO_DURATION: 1500, + MAX_JUMP_HEIGHT: 30, + MIN_JUMP_HEIGHT: 30, + SPEED_DROP_COEFFICIENT: 3, + SPRITE_WIDTH: 262, + START_X_POS: 50, + WIDTH: 42, + WIDTH_RUNNING: 50 + }; + + + /** + * Used in collision detection. + * @type {Array} + */ + Trex.collisionBoxes = { + 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) + ] + }; + + + /** + * Animation states. + * @enum {string} + */ + Trex.status = { + CRASHED: 'CRASHED', + JUMPING: 'JUMPING', + RUNNING: 'RUNNING', + WAITING: 'WAITING' + }; + + /** + * Blinking coefficient. + * @const + */ + Trex.BLINK_TIMING = 500; + + + /** + * Animation config for different states. + * @enum {Object} + */ + Trex.animFrames = { + WAITING: { + frames: [43.5, 0], + msPerFrame: 1000 / 3 + }, + RUNNING: { + frames: [88, 140], + msPerFrame: 1000 / 12 + }, + CRASHED: { + frames: [190, 234], + msPerFrame: 1000 / 60 + }, + JUMPING: { + frames: [88], + msPerFrame: 1000 / 60 + }, + }; + + + Trex.prototype = { + /** + * T-rex player initaliser. + * Sets the t-rex to blink at random intervals. + */ + init: function () { + this.groundYPos = Runner.defaultDimensions.HEIGHT - this.config.HEIGHT - + Runner.config.BOTTOM_PAD; + this.yPos = this.groundYPos; + this.minJumpHeight = this.groundYPos - this.config.MIN_JUMP_HEIGHT; + + this.draw(0, 0); + this.update(0, Trex.status.WAITING); + }, + + /** + * Setter for the jump velocity. + * The approriate drop velocity is also set. + */ + setJumpVelocity: function (setting) { + this.config.INIITAL_JUMP_VELOCITY = -setting; + this.config.DROP_VELOCITY = -setting / 2; + }, + + /** + * Set the animation status. + * @param {!number} deltaTime + * @param {Trex.status} status Optional status to switch to. + */ + update: function (deltaTime, opt_status) { + this.timer += deltaTime; + + // Update the status. + if (opt_status) { + this.status = opt_status; + this.currentFrame = 0; + this.msPerFrame = Trex.animFrames[opt_status].msPerFrame; + this.currentAnimFrames = Trex.animFrames[opt_status].frames; + + if (opt_status == Trex.status.WAITING) { + this.animStartTime = getTimeStamp(); + this.setBlinkDelay(); + } + } + + // Game intro animation, T-rex moves in from the left. + if (this.playingIntro && this.xPos < this.config.START_X_POS) { + this.xPos += Math.round((this.config.START_X_POS / + this.config.INTRO_DURATION) * deltaTime); + } + + if (this.status == Trex.status.WAITING) { + this.blink(getTimeStamp()); + } else { + this.draw(this.currentAnimFrames[this.currentFrame], 0); + } + + // Update the frame position. + if (this.timer >= this.msPerFrame) { + this.currentFrame = this.currentFrame == + 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; + } + }, + + /** + * Draw the t-rex to a particular position. + * @param {number} x + * @param {number} y + */ + draw: function (x, y) { + var sourceX = x; + var sourceY = y; + var sourceWidth = this.status === Trex.status.RUNNING || this.status === Trex.status.JUMPING ? + this.config.WIDTH_RUNNING : this.config.WIDTH; + var sourceHeight = this.config.HEIGHT; + + if (IS_HIDPI) { + sourceX *= 2; + sourceY *= 2; + sourceWidth *= 2; + sourceHeight *= 2; + } + + // Adjustments for sprite sheet position. + sourceX += this.spritePos.x; + sourceY += this.spritePos.y; + + // Standing / running + this.canvasCtx.drawImage(Runner.imageSprite, sourceX, sourceY, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + this.config.WIDTH, this.config.HEIGHT); + }, + + /** + * Sets a random time for the blink to happen. + */ + setBlinkDelay: function () { + this.blinkDelay = Math.ceil(Math.random() * Trex.BLINK_TIMING); + }, + + /** + * Make t-rex blink at random intervals. + * @param {number} time Current time in milliseconds. + */ + blink: function (time) { + var deltaTime = time - this.animStartTime; + + if (deltaTime >= this.blinkDelay) { + this.draw(this.currentAnimFrames[this.currentFrame], 0); + + if (this.currentFrame == 1) { + // Set new random delay to blink. + this.setBlinkDelay(); + this.animStartTime = time; + this.blinkCount++; + } + } + }, + + /** + * Initialise a jump. + * @param {number} speed + */ + startJump: function (speed) { + if (!this.jumping) { + this.update(0, Trex.status.JUMPING); + // 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; + } + }, + + /** + * Jump is complete, falling down. + */ + endJump: function () { + if (this.reachedMinHeight && + this.jumpVelocity < this.config.DROP_VELOCITY) { + this.jumpVelocity = this.config.DROP_VELOCITY; + } + }, + + /** + * Update frame for a jump. + * @param {number} deltaTime + * @param {number} speed + */ + updateJump: function (deltaTime, speed) { + var msPerFrame = Trex.animFrames[this.status].msPerFrame; + var framesElapsed = deltaTime / msPerFrame; + + // Speed drop makes Trex fall faster. + if (this.speedDrop) { + this.yPos += Math.round(this.jumpVelocity * + this.config.SPEED_DROP_COEFFICIENT * framesElapsed); + } else { + this.yPos += Math.round(this.jumpVelocity * framesElapsed); + } + + this.jumpVelocity += this.config.GRAVITY * framesElapsed; + + // Minimum height has been reached. + if (this.yPos < this.minJumpHeight || this.speedDrop) { + this.reachedMinHeight = true; + } + + // Reached max height + if (this.yPos < this.config.MAX_JUMP_HEIGHT || this.speedDrop) { + this.endJump(); + } + + // Back down at ground level. Jump completed. + if (this.yPos > this.groundYPos) { + this.reset(); + this.jumpCount++; + } + + this.update(deltaTime); + }, + + /** + * Set the speed drop. Immediately cancels the current jump. + */ + setSpeedDrop: function () { + this.speedDrop = true; + this.jumpVelocity = 1; + }, + + /** + * @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; + this.jumpCount = 0; + this.highScoreReached = false; + } + }; + + + //****************************************************************************** + + /** + * Handles displaying the distance meter. + * @param {!HTMLCanvasElement} canvas + * @param {Object} spritePos Image position in sprite. + * @param {number} canvasWidth + * @constructor + */ + function DistanceMeter(canvas, spritePos, canvasWidth) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.image = Runner.imageSprite; + this.spritePos = spritePos; + this.x = 0; + this.y = 5; + + this.currentDistance = 0; + this.maxScore = 0; + this.highScore = 0; + this.container = null; + + this.digits = []; + this.acheivement = false; + this.defaultString = ''; + this.flashTimer = 0; + this.flashIterations = 0; + this.invertTrigger = false; + + this.config = DistanceMeter.config; + this.maxScoreUnits = this.config.MAX_DISTANCE_UNITS; + this.init(canvasWidth); + }; + + + /** + * @enum {number} + */ + DistanceMeter.dimensions = { + WIDTH: 10, + HEIGHT: 12, + DEST_WIDTH: 11 + }; + + + /** + * Y positioning of the digits in the sprite sheet. + * X position is always 0. + * @type {Array} + */ + DistanceMeter.yPos = [0, 13, 27, 40, 53, 67, 80, 93, 107, 120]; + + + /** + * Distance meter config. + * @enum {number} + */ + DistanceMeter.config = { + // Number of digits. + MAX_DISTANCE_UNITS: 5, + + // Distance that causes achievement animation. + ACHIEVEMENT_DISTANCE: 100, + + // Used for conversion from pixel distance to a scaled unit. + COEFFICIENT: 0.025, + + // Flash duration in milliseconds. + FLASH_DURATION: 1000 / 4, + + // Flash iterations for achievement animation. + FLASH_ITERATIONS: 3 + }; + + + DistanceMeter.prototype = { + /** + * Initialise the distance meter to '00000'. + * @param {number} width Canvas width in px. + */ + init: function (width) { + var maxDistanceStr = ''; + + this.calcXPos(width); + this.maxScore = this.maxScoreUnits; + for (var i = 0; i < this.maxScoreUnits; i++) { + this.draw(i, 0); + this.defaultString += '0'; + maxDistanceStr += '9'; + } + + this.maxScore = parseInt(maxDistanceStr); + }, + + /** + * Calculate the xPos in the canvas. + * @param {number} canvasWidth + */ + calcXPos: function (canvasWidth) { + this.x = canvasWidth - (DistanceMeter.dimensions.DEST_WIDTH * + (this.maxScoreUnits + 1)); + }, + + /** + * Draw a digit to canvas. + * @param {number} digitPos Position of the digit. + * @param {number} value Digit value 0-9. + * @param {boolean} opt_highScore Whether drawing the high score. + */ + draw: function (digitPos, value, opt_highScore) { + 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; + var targetWidth = DistanceMeter.dimensions.WIDTH; + var targetHeight = DistanceMeter.dimensions.HEIGHT; + + // For high DPI we 2x source values. + if (IS_HIDPI) { + sourceWidth *= 2; + sourceHeight *= 2; + 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.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, sourceY, + sourceWidth, sourceHeight, + targetX, targetY, + targetWidth, targetHeight + ); + + this.canvasCtx.restore(); + }, + + /** + * Covert pixel distance to a 'real' distance. + * @param {number} distance Pixel distance ran. + * @return {number} The 'real' distance ran. + */ + getActualDistance: function (distance) { + return distance ? Math.round(distance * this.config.COEFFICIENT) : 0; + }, + + /** + * Update the distance meter. + * @param {number} distance + * @param {number} deltaTime + * @return {boolean} Whether the acheivement sound fx should be played. + */ + update: function (deltaTime, distance) { + var paint = true; + + 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) { + // Flash score and play sound. + this.acheivement = true; + this.flashTimer = 0; + // playSound = true; + } + + // Create a string representation of the distance with leading 0. + var distanceStr = (this.defaultString + + distance).substr(-this.maxScoreUnits); + this.digits = distanceStr.split(''); + } else { + this.digits = this.defaultString.split(''); + } + } else { + // Control flashing of the score on reaching acheivement. + if (this.flashIterations <= this.config.FLASH_ITERATIONS) { + this.flashTimer += deltaTime; + + if (this.flashTimer < this.config.FLASH_DURATION) { + paint = false; + } else if (this.flashTimer > + this.config.FLASH_DURATION * 2) { + this.flashTimer = 0; + this.flashIterations++; + } + } else { + this.acheivement = false; + this.flashIterations = 0; + this.flashTimer = 0; + } + } + + // Draw the digits if not flashing. + if (paint) { + for (var i = this.digits.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.digits[i])); + } + } + + this.drawHighScore(); + return false; + }, + + /** + * Draw the high score. + */ + drawHighScore: function () { + this.canvasCtx.save(); + this.canvasCtx.globalAlpha = .8; + for (var i = this.highScore.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.highScore[i], 10), true); + } + this.canvasCtx.restore(); + }, + + /** + * Set the highscore as a array string. + * Position of char in the sprite: H - 10, I - 11. + * @param {number} distance Distance ran in pixels. + */ + setHighScore: function (distance) { + distance = this.getActualDistance(distance); + var highScoreStr = (this.defaultString + + distance).substr(-this.maxScoreUnits); + + this.highScore = ['10', '11', ''].concat(highScoreStr.split('')); + }, + + /** + * Reset the distance meter back to '00000'. + */ + reset: function () { + this.update(0); + this.acheivement = false; + } + }; + + + //****************************************************************************** + + /** + * Cloud background item. + * Similar to an obstacle object but without collision boxes. + * @param {HTMLCanvasElement} canvas Canvas element. + * @param {Object} spritePos Position of image in sprite. + * @param {number} containerWidth + */ + function Cloud(canvas, spritePos, containerWidth) { + this.canvas = canvas; + this.canvasCtx = this.canvas.getContext('2d'); + this.spritePos = spritePos; + this.containerWidth = containerWidth; + this.xPos = containerWidth; + this.yPos = 0; + this.remove = false; + this.cloudGap = getRandomNum(Cloud.config.MIN_CLOUD_GAP, + Cloud.config.MAX_CLOUD_GAP); + + this.init(); + }; + + + /** + * Cloud object config. + * @enum {number} + */ + Cloud.config = { + HEIGHT: 14, + MAX_CLOUD_GAP: 400, + MAX_SKY_LEVEL: 30, + MIN_CLOUD_GAP: 100, + MIN_SKY_LEVEL: 71, + WIDTH: 46 + }; + + + Cloud.prototype = { + /** + * Initialise the cloud. Sets the Cloud height. + */ + init: function () { + this.yPos = getRandomNum(Cloud.config.MAX_SKY_LEVEL, + Cloud.config.MIN_SKY_LEVEL); + this.draw(); + }, + + /** + * Draw the cloud. + */ + draw: function () { + this.canvasCtx.save(); + var sourceWidth = Cloud.config.WIDTH; + var sourceHeight = Cloud.config.HEIGHT; + + if (IS_HIDPI) { + sourceWidth = sourceWidth * 2; + sourceHeight = sourceHeight * 2; + } + + this.canvasCtx.drawImage(Runner.imageSprite, this.spritePos.x, + this.spritePos.y, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + Cloud.config.WIDTH, Cloud.config.HEIGHT); + + this.canvasCtx.restore(); + }, + + /** + * Update the cloud position. + * @param {number} speed + */ + update: function (speed) { + if (!this.remove) { + this.xPos -= Math.ceil(speed); + this.draw(); + + // Mark as removeable if no longer in the canvas. + if (!this.isVisible()) { + this.remove = true; + } + } + }, + + /** + * Check if the cloud is visible on the stage. + * @return {boolean} + */ + isVisible: function () { + return this.xPos + Cloud.config.WIDTH > 0; + } + }; + + + //****************************************************************************** + + /** + * Nightmode shows a moon and stars on the horizon. + */ + function NightMode(canvas, spritePos, containerWidth) { + this.spritePos = spritePos; + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.xPos = containerWidth - 50; + this.yPos = 30; + this.currentPhase = 0; + this.opacity = 0; + this.containerWidth = containerWidth; + this.stars = []; + this.drawStars = false; + this.placeStars(); + }; + + /** + * @enum {number} + */ + NightMode.config = { + FADE_SPEED: 0.035, + HEIGHT: 40, + MOON_SPEED: 0.25, + NUM_STARS: 2, + STAR_SIZE: 9, + STAR_SPEED: 0.3, + STAR_MAX_Y: 70, + WIDTH: 20 + }; + + NightMode.phases = [140, 120, 100, 60, 40, 20, 0]; + + NightMode.prototype = { + /** + * Update moving moon, changing phases. + * @param {boolean} activated Whether night mode is activated. + * @param {number} delta + */ + update: function (activated, delta) { + // Moon phase. + if (activated && this.opacity == 0) { + this.currentPhase++; + + if (this.currentPhase >= NightMode.phases.length) { + this.currentPhase = 0; + } + } + + // Fade in / out. + if (activated && (this.opacity < 1 || this.opacity == 0)) { + this.opacity += NightMode.config.FADE_SPEED; + } else if (this.opacity > 0) { + this.opacity -= NightMode.config.FADE_SPEED; + } + + // Set moon positioning. + if (this.opacity > 0) { + this.xPos = this.updateXPos(this.xPos, NightMode.config.MOON_SPEED); + + // Update stars. + if (this.drawStars) { + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i].x = this.updateXPos(this.stars[i].x, + NightMode.config.STAR_SPEED); + } + } + this.draw(); + } else { + this.opacity = 0; + this.placeStars(); + } + this.drawStars = true; + }, + + updateXPos: function (currentPos, speed) { + if (currentPos < -NightMode.config.WIDTH) { + currentPos = this.containerWidth; + } else { + currentPos -= speed; + } + return currentPos; + }, + + draw: function () { + var moonSourceWidth = this.currentPhase == 3 ? NightMode.config.WIDTH * 2 : + NightMode.config.WIDTH; + var moonSourceHeight = NightMode.config.HEIGHT; + var moonSourceX = this.spritePos.x + NightMode.phases[this.currentPhase]; + var moonOutputWidth = moonSourceWidth; + var starSize = NightMode.config.STAR_SIZE; + var starSourceX = Runner.spriteDefinition.LDPI.STAR.x; + + if (IS_HIDPI) { + moonSourceWidth *= 2; + moonSourceHeight *= 2; + moonSourceX = this.spritePos.x + + (NightMode.phases[this.currentPhase] * 2); + starSize *= 2; + starSourceX = Runner.spriteDefinition.HDPI.STAR.x; + } + + this.canvasCtx.save(); + this.canvasCtx.globalAlpha = this.opacity; + + // Stars. + if (this.drawStars) { + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.canvasCtx.drawImage(Runner.imageSprite, + starSourceX, this.stars[i].sourceY, starSize, starSize, + Math.round(this.stars[i].x), this.stars[i].y, + NightMode.config.STAR_SIZE, NightMode.config.STAR_SIZE); + } + } + + // Moon. + this.canvasCtx.drawImage(Runner.imageSprite, moonSourceX, + this.spritePos.y, moonSourceWidth, moonSourceHeight, + Math.round(this.xPos), this.yPos, + moonOutputWidth, NightMode.config.HEIGHT); + + this.canvasCtx.globalAlpha = 1; + this.canvasCtx.restore(); + }, + + // Do star placement. + placeStars: function () { + var segmentSize = Math.round(this.containerWidth / + NightMode.config.NUM_STARS); + + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i] = {}; + this.stars[i].x = getRandomNum(segmentSize * i, segmentSize * (i + 1)); + this.stars[i].y = getRandomNum(0, NightMode.config.STAR_MAX_Y); + + if (IS_HIDPI) { + this.stars[i].sourceY = Runner.spriteDefinition.HDPI.STAR.y + + NightMode.config.STAR_SIZE * 2 * i; + } else { + this.stars[i].sourceY = Runner.spriteDefinition.LDPI.STAR.y + + NightMode.config.STAR_SIZE * i; + } + } + }, + + reset: function () { + this.currentPhase = 0; + this.opacity = 0; + this.update(false); + } + + }; + + + //****************************************************************************** + + /** + * Horizon Line. + * Consists of two connecting lines. Randomly assigns a flat / bumpy horizon. + * @param {HTMLCanvasElement} canvas + * @param {Object} spritePos Horizon position in sprite. + * @constructor + */ + function HorizonLine(canvas, spritePos) { + this.spritePos = spritePos; + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.sourceDimensions = {}; + this.dimensions = HorizonLine.dimensions; + this.sourceXPos = [this.spritePos.x, this.spritePos.x + + this.dimensions.WIDTH]; + this.xPos = []; + this.yPos = 0; + this.bumpThreshold = 0.5; + + this.setSourceDimensions(); + this.draw(); + }; + + + /** + * Horizon line dimensions. + * @enum {number} + */ + HorizonLine.dimensions = { + WIDTH: 600, + HEIGHT: 12, + YPOS: 127 + }; + + + HorizonLine.prototype = { + /** + * Set the source dimensions of the horizon line. + */ + setSourceDimensions: function () { + + for (var dimension in HorizonLine.dimensions) { + if (IS_HIDPI) { + if (dimension != 'YPOS') { + this.sourceDimensions[dimension] = + HorizonLine.dimensions[dimension] * 2; + } + } else { + this.sourceDimensions[dimension] = + HorizonLine.dimensions[dimension]; + } + this.dimensions[dimension] = HorizonLine.dimensions[dimension]; + } + + this.xPos = [0, HorizonLine.dimensions.WIDTH]; + this.yPos = HorizonLine.dimensions.YPOS; + }, + + /** + * Return the crop x position of a type. + */ + getRandomType: function () { + return Math.random() > this.bumpThreshold ? this.dimensions.WIDTH : 0; + }, + + /** + * Draw the horizon line. + */ + draw: function () { + 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(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); + }, + + /** + * Update the x position of an indivdual piece of the line. + * @param {number} pos Line position. + * @param {number} increment + */ + updateXPos: function (pos, increment) { + var line1 = pos; + var line2 = pos == 0 ? 1 : 0; + + this.xPos[line1] -= increment; + this.xPos[line2] = this.xPos[line1] + this.dimensions.WIDTH; + + 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.spritePos.x; + } + }, + + /** + * Update the horizon line. + * @param {number} deltaTime + * @param {number} speed + */ + update: function (deltaTime, speed) { + var increment = Math.floor(speed * (FPS / 1000) * deltaTime); + + if (this.xPos[0] <= 0) { + this.updateXPos(0, increment); + } else { + this.updateXPos(1, increment); + } + this.draw(); + }, + + /** + * Reset horizon to the starting position. + */ + reset: function () { + this.xPos[0] = 0; + this.xPos[1] = HorizonLine.dimensions.WIDTH; + } + }; + + + //****************************************************************************** + + /** + * Horizon background class. + * @param {HTMLCanvasElement} canvas + * @param {Object} spritePos Sprite positioning. + * @param {Object} dimensions Canvas dimensions. + * @param {number} gapCoefficient + * @constructor + */ + 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; + this.nightMode = null; + + // Cloud + this.clouds = []; + this.cloudSpeed = this.config.BG_CLOUD_SPEED; + + // Horizon + this.horizonLine = null; + this.init(); + }; + + + /** + * Horizon config. + * @enum {number} + */ + Horizon.config = { + BG_CLOUD_SPEED: 0.2, + BUMPY_THRESHOLD: .3, + CLOUD_FREQUENCY: .5, + HORIZON_HEIGHT: 16, + MAX_CLOUDS: 6 + }; + + + Horizon.prototype = { + /** + * Initialise the horizon. Just add the line and a cloud. No obstacles. + */ + init: function () { + this.addCloud(); + this.horizonLine = new HorizonLine(this.canvas, this.spritePos.HORIZON); + this.nightMode = new NightMode(this.canvas, this.spritePos.MOON, + this.dimensions.WIDTH); + }, + + /** + * @param {number} deltaTime + * @param {number} currentSpeed + * @param {boolean} updateObstacles Used as an override to prevent + * the obstacles from being updated / added. This happens in the + * ease in section. + * @param {boolean} showNightMode Night mode activated. + */ + update: function (deltaTime, currentSpeed, updateObstacles, showNightMode) { + this.runningTime += deltaTime; + this.horizonLine.update(deltaTime, currentSpeed); + this.nightMode.update(showNightMode); + this.updateClouds(deltaTime, currentSpeed); + + if (updateObstacles) { + this.updateObstacles(deltaTime, currentSpeed); + } + }, + + /** + * Update the cloud positions. + * @param {number} deltaTime + * @param {number} currentSpeed + */ + updateClouds: function (deltaTime, speed) { + var cloudSpeed = this.cloudSpeed / 1000 * deltaTime * speed; + var numClouds = this.clouds.length; + + if (numClouds) { + for (var i = numClouds - 1; i >= 0; i--) { + this.clouds[i].update(cloudSpeed); + } + + var lastCloud = this.clouds[numClouds - 1]; + + // Check for adding a new cloud. + if (numClouds < this.config.MAX_CLOUDS && + (this.dimensions.WIDTH - lastCloud.xPos) > lastCloud.cloudGap && + this.cloudFrequency > Math.random()) { + this.addCloud(); + } + + // Remove expired clouds. + this.clouds = this.clouds.filter(function (obj) { + return !obj.remove; + }); + } else { + this.addCloud(); + } + }, + + /** + * Update the obstacle positions. + * @param {number} deltaTime + * @param {number} currentSpeed + */ + updateObstacles: function (deltaTime, currentSpeed) { + // Obstacles, move to Horizon layer. + var updatedObstacles = this.obstacles.slice(0); + + for (var i = 0; i < this.obstacles.length; i++) { + var obstacle = this.obstacles[i]; + obstacle.update(deltaTime, currentSpeed); + + // Clean up existing obstacles. + if (obstacle.remove) { + updatedObstacles.shift(); + } + } + this.obstacles = updatedObstacles; + + if (this.obstacles.length > 0) { + var lastObstacle = this.obstacles[this.obstacles.length - 1]; + + if (lastObstacle && !lastObstacle.followingObstacleCreated && + lastObstacle.isVisible() && + (lastObstacle.xPos + lastObstacle.width + lastObstacle.gap) < + this.dimensions.WIDTH) { + this.addNewObstacle(currentSpeed); + lastObstacle.followingObstacleCreated = true; + } + } else { + // Create new obstacles. + this.addNewObstacle(currentSpeed); + } + }, + + removeFirstObstacle: function () { + this.obstacles.shift(); + }, + + /** + * Add a new obstacle. + * @param {number} currentSpeed + */ + addNewObstacle: function (currentSpeed) { + var obstacleTypeIndex = getRandomNum(0, Obstacle.types.length - 1); + var obstacleType = Obstacle.types[obstacleTypeIndex]; + + // 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, obstacleType.width)); + + 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; + }, + + /** + * Reset the horizon layer. + * Remove existing obstacles and reposition the horizon line. + */ + reset: function () { + this.obstacles = []; + this.horizonLine.reset(); + this.nightMode.reset(); + }, + + /** + * Update the canvas width and scaling. + * @param {number} width Canvas width. + * @param {number} height Canvas height. + */ + resize: function (width, height) { + this.canvas.width = width; + this.canvas.height = height; + }, + + /** + * Add a new cloud to the horizon. + */ + addCloud: function () { + this.clouds.push(new Cloud(this.canvas, this.spritePos.CLOUD, + this.dimensions.WIDTH)); + } + }; +})(); + +new Runner('.interstitial-wrapper'); \ No newline at end of file diff --git a/ui/games/CapybaraRunner.tsx b/ui/games/CapybaraRunner.tsx new file mode 100644 index 0000000000..dc23d40de2 --- /dev/null +++ b/ui/games/CapybaraRunner.tsx @@ -0,0 +1,56 @@ +/* eslint-disable @next/next/no-img-element */ +import { Box, Text, Button, Flex } from '@chakra-ui/react'; +import Script from 'next/script'; +import React from 'react'; + +import config from 'configs/app'; + +const easterEggBadgeFeature = config.features.easterEggBadge; + +const CapybaraRunner = () => { + const [ hasReachedHighScore, setHasReachedHighScore ] = React.useState(false); + + React.useEffect(() => { + const preventDefaultKeys = (e: KeyboardEvent) => { + if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'ArrowDown') { + e.preventDefault(); + } + }; + + const handleHighScore = () => { + setHasReachedHighScore(true); + }; + + window.addEventListener('reachedHighScore', handleHighScore); + window.addEventListener('keydown', preventDefaultKeys); + + return () => { + window.removeEventListener('keydown', preventDefaultKeys); + window.removeEventListener('reachedHighScore', handleHighScore); + }; + }, []); + + return ( + <> +