| // Copyright 2014 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {assert} from 'chrome://resources/js/assert.js'; |
| import {loadTimeData} from 'chrome://resources/js/load_time_data.js'; |
| |
| import {HIDDEN_CLASS} from '../constants.js'; |
| |
| import {DEFAULT_DIMENSIONS, FPS, IS_HIDPI, IS_IOS, IS_MOBILE, IS_RTL} from './constants.js'; |
| import type {Dimensions} from './dimensions.js'; |
| import {DistanceMeter} from './distance_meter.js'; |
| import type {BaseConfig, Config, ConfigProvider, GameModeConfig} from './game_config.js'; |
| import {GameOverPanel} from './game_over_panel.js'; |
| import type {GameStateProvider} from './game_state_provider.js'; |
| import type {GeneratedSoundFxProvider} from './generated_sound_fx.js'; |
| import {GeneratedSoundFx} from './generated_sound_fx.js'; |
| import {Horizon} from './horizon.js'; |
| import type {ImageSpriteProvider} from './image_sprite_provider.js'; |
| import type {Obstacle} from './obstacle.js'; |
| import type {SpriteDefinition, SpriteDefinitionByType, SpritePositions} from './offline_sprite_definitions.js'; |
| import {CollisionBox, GAME_TYPE, spriteDefinitionByType} from './offline_sprite_definitions.js'; |
| import {Status as TrexStatus, Trex} from './trex.js'; |
| import {getTimeStamp} from './utils.js'; |
| |
| enum A11yStrings { |
| ARIA_LABEL = 'dinoGameA11yAriaLabel', |
| DESCRIPTION = 'dinoGameA11yDescription', |
| GAME_OVER = 'dinoGameA11yGameOver', |
| HIGH_SCORE = 'dinoGameA11yHighScore', |
| JUMP = 'dinoGameA11yJump', |
| STARTED = 'dinoGameA11yStartGame', |
| SPEED_LABEL = 'dinoGameA11ySpeedToggle', |
| } |
| |
| const defaultBaseConfig: BaseConfig = { |
| audiocueProximityThreshold: 190, |
| audiocueProximityThresholdMobileA11y: 250, |
| bgCloudSpeed: 0.2, |
| bottomPad: 10, |
| // Scroll Y threshold at which the game can be activated. |
| canvasInViewOffset: -10, |
| clearTime: 3000, |
| cloudFrequency: 0.5, |
| fadeDuration: 1, |
| flashDuration: 1000, |
| gameoverClearTime: 1200, |
| initialJumpVelocity: 12, |
| invertFadeDuration: 12000, |
| maxBlinkCount: 3, |
| maxClouds: 6, |
| maxObstacleLength: 3, |
| maxObstacleDuplication: 2, |
| resourceTemplateId: 'audio-resources', |
| speed: 6, |
| speedDropCoefficient: 3, |
| arcadeModeInitialTopPosition: 35, |
| arcadeModeTopPositionPercent: 0.1, |
| }; |
| |
| const normalModeConfig: GameModeConfig = { |
| acceleration: 0.001, |
| audiocueProximityThreshold: 190, |
| audiocueProximityThresholdMobileA11y: 250, |
| gapCoefficient: 0.6, |
| invertDistance: 700, |
| maxSpeed: 13, |
| mobileSpeedCoefficient: 1.2, |
| speed: 6, |
| }; |
| |
| const slowModeConfig: GameModeConfig = { |
| acceleration: 0.0005, |
| audiocueProximityThreshold: 170, |
| audiocueProximityThresholdMobileA11y: 220, |
| gapCoefficient: 0.3, |
| invertDistance: 350, |
| maxSpeed: 9, |
| mobileSpeedCoefficient: 1.5, |
| speed: 4.2, |
| }; |
| |
| type TrexDebugConfigSetting = 'gravity'|'minJumpHeight'|'speedDropCoefficient'| |
| 'initialJumpVelocity'|'speed'; |
| |
| /** |
| * CSS class names. |
| */ |
| enum RunnerClasses { |
| ARCADE_MODE = 'arcade-mode', |
| CANVAS = 'runner-canvas', |
| CONTAINER = 'runner-container', |
| CRASHED = 'crashed', |
| ICON = 'icon-offline', |
| ICON_DISABLED = 'icon-disabled', |
| INVERTED = 'inverted', |
| SNACKBAR = 'snackbar', |
| SNACKBAR_SHOW = 'snackbar-show', |
| TOUCH_CONTROLLER = 'controller', |
| } |
| |
| /** |
| * Sound FX. Reference to the ID of the audio tag on interstitial page. |
| */ |
| enum RunnerSounds { |
| BUTTON_PRESS = 'offline-sound-press', |
| HIT = 'offline-sound-hit', |
| SCORE = 'offline-sound-reached', |
| } |
| |
| /** |
| * Key code mapping. |
| */ |
| const runnerKeycodes: {jump: number[], duck: number[], restart: number[]} = { |
| jump: [38, 32], // Up, spacebar |
| duck: [40], // Down |
| restart: [13], // Enter |
| }; |
| |
| enum RunnerEvents { |
| ANIM_END = 'webkitAnimationEnd', |
| CLICK = 'click', |
| KEYDOWN = 'keydown', |
| KEYUP = 'keyup', |
| POINTERDOWN = 'pointerdown', |
| POINTERUP = 'pointerup', |
| RESIZE = 'resize', |
| TOUCHEND = 'touchend', |
| TOUCHSTART = 'touchstart', |
| VISIBILITY = 'visibilitychange', |
| BLUR = 'blur', |
| FOCUS = 'focus', |
| LOAD = 'load', |
| GAMEPADCONNECTED = 'gamepadconnected', |
| } |
| |
| let runnerInstance: Runner|null = null; |
| |
| const ARCADE_MODE_URL: string = 'chrome://dino/'; |
| |
| const RESOURCE_POSTFIX: string = 'offline-resources-'; |
| |
| /** |
| * T-Rex runner. |
| */ |
| export class Runner implements ImageSpriteProvider, GameStateProvider, |
| ConfigProvider, GeneratedSoundFxProvider { |
| private outerContainerEl: HTMLElement; |
| private containerEl: HTMLElement|null = null; |
| // A div to intercept touch events. Only set while (playing && useTouch). |
| private touchController: HTMLElement|null = null; |
| private canvas: HTMLCanvasElement|null = null; |
| private canvasCtx: CanvasRenderingContext2D|null = null; |
| private a11yStatusEl: HTMLElement|null = null; |
| private slowSpeedCheckboxLabel: HTMLElement|null = null; |
| private slowSpeedCheckbox: HTMLInputElement|null = null; |
| private slowSpeedToggleEl: HTMLElement|null = null; |
| private origImageSprite: HTMLImageElement|null = null; |
| private altCommonImageSprite: HTMLImageElement|null = null; |
| private altGameImageSprite: HTMLImageElement|null = null; |
| private imageSprite: HTMLImageElement|null = null; |
| |
| private config: Config; |
| // Logical dimensions of the container. |
| private dimensions: Dimensions = DEFAULT_DIMENSIONS; |
| private gameType: (keyof SpriteDefinitionByType|null) = null; |
| private spriteDefinition: SpriteDefinition = spriteDefinitionByType.original; |
| private spriteDef: SpritePositions|null = null; |
| |
| // Alt game mode state. |
| private altGameModeActive: boolean = false; |
| private altGameModeFlashTimer: number|null = null; |
| private altGameAssetsFailedToLoad: boolean = false; |
| private fadeInTimer: number = 0; |
| |
| // UI components. |
| private tRex: Trex|null = null; |
| private distanceMeter: DistanceMeter|null = null; |
| private gameOverPanel: GameOverPanel|null = null; |
| private horizon: Horizon|null = null; |
| |
| private msPerFrame: number = 1000 / FPS; |
| private time: number = 0; |
| private distanceRan: number = 0; |
| private runningTime: number = 0; |
| private currentSpeed: number; |
| private resizeTimerId?: number; |
| private raqId: number = 0; |
| private playCount: number = 0; |
| |
| // Whether the easter egg has been disabled. CrOS enterprise enrolled devices. |
| private isDisabled: boolean = loadTimeData.valueExists('disabledEasterEgg'); |
| // Whether the easter egg has been activated. |
| private activated: boolean = false; |
| // Whether the game is currently in play state. |
| private playing: boolean = false; |
| private playingIntro: boolean = false; |
| private crashed: boolean = false; |
| private paused: boolean = false; |
| private inverted: boolean = false; |
| private isDarkMode: boolean = false; |
| private updatePending: boolean = false; |
| private hasSlowdownInternal: boolean = false; |
| private hasAudioCuesInternal: boolean = false; |
| |
| private highestScore: number = 0; |
| private syncHighestScore: boolean = false; |
| |
| private invertTimer: number = 0; |
| private invertTrigger: boolean = false; |
| |
| private soundFx: |
| Partial<{[K in keyof typeof RunnerSounds]: AudioBuffer}> = {}; |
| private audioContext: AudioContext|null = null; |
| private generatedSoundFx: GeneratedSoundFx|null = null; |
| |
| // Gamepad state. |
| private pollingGamepads: boolean = false; |
| private gamepadIndex?: number; |
| private previousGamepad: Gamepad|null = null; |
| |
| |
| // Initialize the singleton instance of Runner. Should only be called once. |
| static initializeInstance(outerContainerId: string, config?: Config): Runner { |
| assert(runnerInstance === null); |
| runnerInstance = new Runner(outerContainerId, config); |
| runnerInstance.loadImages(); |
| |
| return runnerInstance; |
| } |
| |
| static getInstance(): Runner { |
| assert(runnerInstance); |
| return runnerInstance; |
| } |
| |
| private constructor(outerContainerId: string, configParam?: Config) { |
| const outerContainerElement = |
| document.querySelector<HTMLElement>(outerContainerId); |
| assert(outerContainerElement); |
| this.outerContainerEl = outerContainerElement; |
| |
| this.config = |
| configParam || Object.assign({}, defaultBaseConfig, normalModeConfig); |
| |
| this.currentSpeed = this.config.speed; |
| |
| if (this.isDisabled) { |
| this.setupDisabledRunner(); |
| return; |
| } |
| |
| if (this.isAltGameModeEnabled()) { |
| this.initAltGameType(); |
| } |
| |
| window.initializeEasterEggHighScore = this.initializeHighScore.bind(this); |
| } |
| |
| // GameStateProvider implementation. |
| get hasSlowdown(): boolean { |
| return this.hasSlowdownInternal; |
| } |
| |
| // GameStateProvider implementation. |
| get hasAudioCues(): boolean { |
| return this.hasAudioCuesInternal; |
| } |
| |
| /** |
| * Whether an alternative game mode is enabled, returns true if the load time |
| * data specifies it and its assets loaded successfully. Returns false |
| * otherwise. |
| * GameStateProvider implementation. |
| */ |
| isAltGameModeEnabled(): boolean { |
| if (this.altGameAssetsFailedToLoad) { |
| return false; |
| } |
| return loadTimeData.valueExists('enableAltGameMode'); |
| } |
| |
| // GeneratedSoundFxProvider implementation. |
| getGeneratedSoundFx(): GeneratedSoundFx { |
| assert(this.generatedSoundFx); |
| return this.generatedSoundFx; |
| } |
| |
| // ImageSpriteProvider implementation. |
| getSpriteDefinition(): SpriteDefinition { |
| return this.spriteDefinition; |
| } |
| |
| // ImageSpriteProvider implementation. |
| getOrigImageSprite(): HTMLImageElement { |
| assert(this.origImageSprite); |
| return this.origImageSprite; |
| } |
| |
| // ImageSpriteProvider implementation. |
| getRunnerImageSprite(): HTMLImageElement { |
| assert(this.imageSprite); |
| return this.imageSprite; |
| } |
| |
| // ImageSpriteProvider implementation. |
| getRunnerAltGameImageSprite(): HTMLImageElement|null { |
| return this.altGameImageSprite; |
| } |
| |
| // ImageSpriteProvider implementation. |
| getAltCommonImageSprite(): HTMLImageElement|null { |
| return this.altCommonImageSprite; |
| } |
| |
| // ConfigProvider implementation. |
| getConfig(): Config { |
| return this.config; |
| } |
| |
| /** |
| * Initialize alternative game type. |
| */ |
| private initAltGameType() { |
| assert(loadTimeData.valueExists('altGameType')); |
| if (GAME_TYPE.length > 0) { |
| const parsedValue = |
| Number.parseInt(loadTimeData.getValue('altGameType'), 10); |
| const type = GAME_TYPE[parsedValue - 1]; |
| this.gameType = type || null; |
| } |
| } |
| |
| /** |
| * For disabled instances, set up a snackbar with the disabled message. |
| */ |
| private setupDisabledRunner() { |
| this.containerEl = document.createElement('div'); |
| this.containerEl.className = RunnerClasses.SNACKBAR; |
| this.containerEl.textContent = loadTimeData.getValue('disabledEasterEgg'); |
| this.outerContainerEl.appendChild(this.containerEl); |
| |
| // Show notification when the activation key is pressed. |
| document.addEventListener(RunnerEvents.KEYDOWN, e => { |
| if (runnerKeycodes.jump.includes(e.keyCode)) { |
| assert(this.containerEl); |
| this.containerEl.classList.add(RunnerClasses.SNACKBAR_SHOW); |
| const iconElement = document.querySelector('.icon'); |
| assert(iconElement); |
| iconElement.classList.add(RunnerClasses.ICON_DISABLED); |
| } |
| }); |
| } |
| |
| /** |
| * Sets individual settings for debugging. |
| */ |
| updateConfigSetting<K extends keyof Config>(setting: K, value: Config[K]) { |
| this.config[setting] = value; |
| } |
| |
| /** |
| * Sets individual settings for debugging. |
| */ |
| updateTrexConfigSetting(setting: TrexDebugConfigSetting, value: number) { |
| assert(this.tRex); |
| |
| switch (setting) { |
| case 'gravity': |
| case 'minJumpHeight': |
| case 'speedDropCoefficient': |
| this.tRex.config[setting] = value; |
| break; |
| case 'initialJumpVelocity': |
| this.tRex.setJumpVelocity(value); |
| break; |
| case 'speed': |
| this.setSpeed(value); |
| break; |
| default: |
| break; |
| } |
| } |
| |
| /** |
| * Creates an on page image element from the base 64 encoded string source. |
| * @param resourceName Name in data object, |
| * @return The created element. |
| */ |
| private createImageElement(resourceName: string): HTMLImageElement|null { |
| const imgSrc = loadTimeData.valueExists(resourceName) ? |
| loadTimeData.getString(resourceName) : |
| null; |
| |
| if (imgSrc) { |
| const el = document.createElement('img'); |
| el.id = resourceName; |
| el.src = imgSrc; |
| const resourcesElement = document.getElementById('offline-resources'); |
| assert(resourcesElement); |
| resourcesElement.appendChild(el); |
| return el; |
| } |
| return null; |
| } |
| |
| /** |
| * Cache the appropriate image sprite from the page and get the sprite sheet |
| * definition. |
| */ |
| private loadImages() { |
| let scale = '1x'; |
| this.spriteDef = this.getSpriteDefinition().ldpi; |
| if (IS_HIDPI) { |
| scale = '2x'; |
| this.spriteDef = this.getSpriteDefinition().hdpi; |
| } |
| |
| const imageSpriteElement = document.querySelector<HTMLImageElement>( |
| `#${RESOURCE_POSTFIX + scale}`); |
| assert(imageSpriteElement); |
| this.imageSprite = imageSpriteElement; |
| |
| if (this.gameType) { |
| this.altGameImageSprite = |
| (this.createImageElement('altGameSpecificImage' + scale)); |
| this.altCommonImageSprite = |
| (this.createImageElement('altGameCommonImage' + scale)); |
| } |
| this.origImageSprite = this.getRunnerImageSprite(); |
| |
| // Disable the alt game mode if the sprites can't be loaded. |
| if (!this.getRunnerAltGameImageSprite() === null || |
| this.getAltCommonImageSprite() === null) { |
| this.altGameAssetsFailedToLoad = true; |
| this.altGameModeActive = false; |
| } |
| |
| if (this.getRunnerImageSprite().complete) { |
| this.init(); |
| } else { |
| // If the images are not yet loaded, add a listener. |
| this.getRunnerImageSprite().addEventListener( |
| RunnerEvents.LOAD, this.init.bind(this)); |
| } |
| } |
| |
| /** |
| * Load and decode base 64 encoded sounds. |
| */ |
| private loadSounds() { |
| if (IS_IOS) { |
| return; |
| } |
| this.audioContext = new AudioContext(); |
| |
| const resourceTemplateElement = document.querySelector<HTMLTemplateElement>( |
| `#${this.config.resourceTemplateId}`); |
| assert(resourceTemplateElement); |
| const resourceTemplate = resourceTemplateElement.content; |
| |
| for (const sound in RunnerSounds) { |
| const audioElement = resourceTemplate.querySelector<HTMLAudioElement>( |
| `#${RunnerSounds[sound as keyof typeof RunnerSounds]}`); |
| assert(audioElement); |
| let soundSrc = audioElement.src; |
| soundSrc = soundSrc.substr(soundSrc.indexOf(',') + 1); |
| const buffer = decodeBase64ToArrayBuffer(soundSrc); |
| |
| // Async, so no guarantee of order in array. |
| this.audioContext.decodeAudioData(buffer, audioBuffer => { |
| this.soundFx = { |
| ...this.soundFx, |
| [sound]: audioBuffer, |
| }; |
| }); |
| } |
| } |
| |
| /** |
| * Sets the game speed. Adjust the speed accordingly if on a smaller screen. |
| */ |
| private setSpeed(newSpeed?: number) { |
| const speed = newSpeed || this.currentSpeed; |
| |
| // Reduce the speed on smaller mobile screens. |
| if (this.dimensions.width < DEFAULT_DIMENSIONS.width) { |
| const mobileSpeed = this.hasSlowdown ? speed : |
| speed * this.dimensions.width / |
| DEFAULT_DIMENSIONS.width * this.config.mobileSpeedCoefficient; |
| this.currentSpeed = mobileSpeed > speed ? speed : mobileSpeed; |
| } else if (newSpeed) { |
| this.currentSpeed = newSpeed; |
| } |
| } |
| |
| /** |
| * Game initialiser. |
| */ |
| private init() { |
| assert(this.spriteDef); |
| const iconElement = |
| document.querySelector<HTMLElement>('.' + RunnerClasses.ICON); |
| assert(iconElement); |
| |
| // Hide the static icon. |
| iconElement.style.visibility = 'hidden'; |
| |
| if (this.isArcadeMode()) { |
| document.title = |
| document.title + ' - ' + getA11yString(A11yStrings.ARIA_LABEL); |
| } |
| |
| this.adjustDimensions(); |
| this.setSpeed(); |
| |
| const ariaLabel = getA11yString(A11yStrings.ARIA_LABEL); |
| this.containerEl = document.createElement('div'); |
| this.containerEl.setAttribute('role', IS_MOBILE ? 'button' : 'application'); |
| this.containerEl.setAttribute('tabindex', '0'); |
| this.containerEl.setAttribute( |
| 'title', getA11yString(A11yStrings.DESCRIPTION)); |
| this.containerEl.setAttribute('aria-label', ariaLabel); |
| |
| this.containerEl.className = RunnerClasses.CONTAINER; |
| |
| // Player canvas container. |
| this.canvas = createCanvas( |
| this.containerEl, this.dimensions.width, this.dimensions.height); |
| |
| // Live region for game status updates. |
| this.a11yStatusEl = document.createElement('span'); |
| this.a11yStatusEl.className = 'offline-runner-live-region'; |
| this.a11yStatusEl.setAttribute('aria-live', 'assertive'); |
| this.a11yStatusEl.textContent = ''; |
| |
| // Add checkbox to slow down the game. |
| this.slowSpeedCheckboxLabel = document.createElement('label'); |
| this.slowSpeedCheckboxLabel.className = 'slow-speed-option hidden'; |
| this.slowSpeedCheckboxLabel.textContent = |
| getA11yString(A11yStrings.SPEED_LABEL); |
| |
| this.slowSpeedCheckbox = document.createElement('input'); |
| this.slowSpeedCheckbox.setAttribute('type', 'checkbox'); |
| this.slowSpeedCheckbox.setAttribute( |
| 'title', getA11yString(A11yStrings.SPEED_LABEL)); |
| this.slowSpeedCheckbox.setAttribute('tabindex', '0'); |
| this.slowSpeedCheckbox.setAttribute('checked', 'checked'); |
| |
| this.slowSpeedToggleEl = document.createElement('span'); |
| this.slowSpeedToggleEl.className = 'slow-speed-toggle'; |
| |
| this.slowSpeedCheckboxLabel.appendChild(this.slowSpeedCheckbox); |
| this.slowSpeedCheckboxLabel.appendChild(this.slowSpeedToggleEl); |
| |
| if (IS_IOS) { |
| this.outerContainerEl.appendChild(this.a11yStatusEl); |
| } else { |
| this.containerEl.appendChild(this.a11yStatusEl); |
| } |
| |
| const canvasContext = this.canvas.getContext('2d'); |
| assert(canvasContext); |
| this.canvasCtx = canvasContext; |
| this.canvasCtx.fillStyle = '#f7f7f7'; |
| this.canvasCtx.fill(); |
| updateCanvasScaling(this.canvas); |
| |
| // Horizon contains clouds, obstacles and the ground. |
| this.horizon = new Horizon( |
| this.canvas, this.spriteDef, this.dimensions, |
| this.config.gapCoefficient, /* resourceProvider= */ this); |
| |
| // Distance meter |
| this.distanceMeter = new DistanceMeter( |
| this.canvas, this.spriteDef.textSprite, this.dimensions.width, |
| /* imageSpriteProvider= */ this); |
| |
| // Draw t-rex |
| this.tRex = new Trex( |
| this.canvas, this.spriteDef.tRex, /* resourceProvider= */ this); |
| |
| this.outerContainerEl.appendChild(this.containerEl); |
| this.outerContainerEl.appendChild(this.slowSpeedCheckboxLabel); |
| |
| this.startListening(); |
| this.update(); |
| |
| window.addEventListener( |
| RunnerEvents.RESIZE, this.debounceResize.bind(this)); |
| |
| // Handle dark mode |
| const darkModeMediaQuery = |
| window.matchMedia('(prefers-color-scheme: dark)'); |
| this.isDarkMode = darkModeMediaQuery && darkModeMediaQuery.matches; |
| darkModeMediaQuery.addListener((e) => { |
| this.isDarkMode = e.matches; |
| }); |
| } |
| |
| /** |
| * Create the touch controller. A div that covers whole screen. |
| */ |
| private createTouchController() { |
| this.touchController = document.createElement('div'); |
| this.touchController.className = RunnerClasses.TOUCH_CONTROLLER; |
| this.touchController.addEventListener(RunnerEvents.TOUCHSTART, this); |
| this.touchController.addEventListener(RunnerEvents.TOUCHEND, this); |
| this.outerContainerEl.appendChild(this.touchController); |
| } |
| |
| /** |
| * Debounce the resize event. |
| */ |
| private debounceResize() { |
| if (this.resizeTimerId === undefined) { |
| this.resizeTimerId = setInterval(this.adjustDimensions.bind(this), 250); |
| } |
| } |
| |
| /** |
| * Adjust game space dimensions on resize. |
| */ |
| private adjustDimensions() { |
| clearInterval(this.resizeTimerId); |
| this.resizeTimerId = undefined; |
| |
| const boxStyles = window.getComputedStyle(this.outerContainerEl); |
| const padding = Number( |
| boxStyles.paddingLeft.substr(0, boxStyles.paddingLeft.length - 2)); |
| |
| this.dimensions.width = this.outerContainerEl.offsetWidth - padding * 2; |
| if (this.isArcadeMode()) { |
| this.dimensions.width = |
| Math.min(DEFAULT_DIMENSIONS.width, this.dimensions.width); |
| if (this.activated) { |
| this.setArcadeModeContainerScale(); |
| } |
| } |
| |
| // Redraw the elements back onto the canvas. |
| if (this.canvas) { |
| assert(this.distanceMeter); |
| assert(this.horizon); |
| assert(this.tRex); |
| assert(this.containerEl); |
| this.canvas.width = this.dimensions.width; |
| this.canvas.height = this.dimensions.height; |
| |
| updateCanvasScaling(this.canvas); |
| |
| this.distanceMeter.calcXpos(this.dimensions.width); |
| this.clearCanvas(); |
| this.horizon.update(0, 0, true, /*showNightMode = */ false); |
| 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(this.altGameModeActive, this.tRex); |
| } |
| } |
| } |
| |
| /** |
| * Play the game intro. |
| * Canvas container width expands out to the full width. |
| */ |
| private playIntro() { |
| if (!this.activated && !this.crashed) { |
| assert(this.tRex); |
| assert(this.containerEl); |
| this.playingIntro = true; |
| this.tRex.playingIntro = true; |
| |
| // CSS animation definition. |
| const keyframes = '@-webkit-keyframes intro { ' + |
| 'from { width:' + this.tRex.config.width + 'px }' + |
| 'to { width: ' + this.dimensions.width + 'px }' + |
| '}'; |
| const styleSheet = document.styleSheets[0]; |
| assert(styleSheet); |
| styleSheet.insertRule(keyframes, 0); |
| |
| this.containerEl.addEventListener( |
| RunnerEvents.ANIM_END, this.startGame.bind(this)); |
| |
| this.containerEl.style.webkitAnimation = 'intro .4s ease-out 1 both'; |
| this.containerEl.style.width = this.dimensions.width + 'px'; |
| |
| this.setPlayStatus(true); |
| this.activated = true; |
| } else if (this.crashed) { |
| this.restart(); |
| } |
| } |
| |
| |
| /** |
| * Update the game status to started. |
| */ |
| private startGame() { |
| assert(this.containerEl); |
| assert(this.tRex); |
| if (this.isArcadeMode()) { |
| this.setArcadeMode(); |
| } |
| this.toggleSpeed(); |
| this.runningTime = 0; |
| this.playingIntro = false; |
| this.tRex.playingIntro = false; |
| this.containerEl.style.webkitAnimation = ''; |
| this.playCount++; |
| |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx().background(); |
| this.containerEl.setAttribute('title', getA11yString(A11yStrings.JUMP)); |
| } |
| |
| // Handle tabbing off the page. Pause the current game. |
| document.addEventListener( |
| RunnerEvents.VISIBILITY, this.onVisibilityChange.bind(this)); |
| |
| window.addEventListener( |
| RunnerEvents.BLUR, this.onVisibilityChange.bind(this)); |
| |
| window.addEventListener( |
| RunnerEvents.FOCUS, this.onVisibilityChange.bind(this)); |
| } |
| |
| private clearCanvas() { |
| assert(this.canvasCtx); |
| this.canvasCtx.clearRect( |
| 0, 0, this.dimensions.width, this.dimensions.height); |
| } |
| |
| /** |
| * Checks whether the canvas area is in the viewport of the browser |
| * through the current scroll position. |
| */ |
| private isCanvasInView(): boolean { |
| assert(this.containerEl); |
| return this.containerEl.getBoundingClientRect().top > |
| this.config.canvasInViewOffset; |
| } |
| |
| /** |
| * Enable the alt game mode. Switching out the sprites. |
| */ |
| private enableAltGameMode() { |
| this.imageSprite = this.getRunnerAltGameImageSprite(); |
| assert(this.gameType); |
| assert(this.tRex); |
| assert(this.horizon); |
| this.spriteDefinition = spriteDefinitionByType[this.gameType]; |
| |
| if (IS_HIDPI) { |
| this.spriteDef = this.getSpriteDefinition().hdpi; |
| } else { |
| this.spriteDef = this.getSpriteDefinition().ldpi; |
| } |
| |
| this.altGameModeActive = true; |
| this.tRex.enableAltGameMode(this.spriteDef.tRex); |
| this.horizon.enableAltGameMode(this.spriteDef); |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx()?.background(); |
| } |
| } |
| |
| /** |
| * Update the game frame and schedules the next one. |
| */ |
| private update() { |
| assert(this.tRex); |
| |
| this.updatePending = false; |
| |
| const now = getTimeStamp(); |
| let deltaTime = now - (this.time || now); |
| |
| // Flashing when switching game modes. |
| if (this.altGameModeFlashTimer !== null) { |
| if (this.altGameModeFlashTimer <= 0) { |
| this.altGameModeFlashTimer = null; |
| this.tRex.setFlashing(false); |
| this.enableAltGameMode(); |
| } else if (this.altGameModeFlashTimer > 0) { |
| this.altGameModeFlashTimer -= deltaTime; |
| this.tRex.update(deltaTime); |
| deltaTime = 0; |
| } |
| } |
| |
| this.time = now; |
| |
| if (this.playing) { |
| assert(this.distanceMeter); |
| assert(this.horizon); |
| assert(this.canvasCtx); |
| |
| this.clearCanvas(); |
| |
| // Additional fade in - Prevents jump when switching sprites |
| if (this.altGameModeActive && |
| this.fadeInTimer <= this.config.fadeDuration) { |
| this.fadeInTimer += deltaTime / 1000; |
| this.canvasCtx.globalAlpha = this.fadeInTimer; |
| } else { |
| this.canvasCtx.globalAlpha = 1; |
| } |
| |
| if (this.tRex.jumping) { |
| this.tRex.updateJump(deltaTime); |
| } |
| |
| this.runningTime += deltaTime; |
| const hasObstacles = this.runningTime > this.config.clearTime; |
| |
| // 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, /* showNightMode = */ false); |
| } else if (!this.crashed) { |
| const showNightMode = this.isDarkMode !== this.inverted; |
| deltaTime = !this.activated ? 0 : deltaTime; |
| this.horizon.update( |
| deltaTime, this.currentSpeed, hasObstacles, showNightMode); |
| } |
| |
| const firstObstacle = this.horizon.obstacles[0]; |
| |
| // Check for collisions. |
| let collision = hasObstacles && firstObstacle && |
| this.checkForCollision(firstObstacle, this.tRex); |
| |
| // For a11y, audio cues. |
| if (this.hasAudioCuesInternal && hasObstacles) { |
| assert(firstObstacle); |
| const jumpObstacle = firstObstacle.typeConfig.type !== 'collectable'; |
| |
| if (!firstObstacle.jumpAlerted) { |
| const threshold = this.config.audiocueProximityThreshold; |
| const adjProximityThreshold = threshold + |
| (threshold * Math.log10(this.currentSpeed / this.config.speed)); |
| |
| if (firstObstacle.xPos < adjProximityThreshold) { |
| if (jumpObstacle) { |
| this.getGeneratedSoundFx().jump(); |
| } |
| firstObstacle.jumpAlerted = true; |
| } |
| } |
| } |
| |
| // Activated alt game mode. |
| if (this.isAltGameModeEnabled() && collision && firstObstacle && |
| firstObstacle.typeConfig.type === 'collectable') { |
| this.horizon.removeFirstObstacle(); |
| this.tRex.setFlashing(true); |
| collision = false; |
| this.altGameModeFlashTimer = this.config.flashDuration; |
| this.runningTime = 0; |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx().collect(); |
| } |
| } |
| |
| if (!collision) { |
| this.distanceRan += this.currentSpeed * deltaTime / this.msPerFrame; |
| |
| if (this.currentSpeed < this.config.maxSpeed) { |
| this.currentSpeed += this.config.acceleration; |
| } |
| } else { |
| this.gameOver(); |
| } |
| |
| const playAchievementSound = |
| this.distanceMeter.update(deltaTime, Math.ceil(this.distanceRan)); |
| |
| if (!this.hasAudioCuesInternal && playAchievementSound) { |
| this.playSound(this.soundFx.SCORE); |
| } |
| |
| // Night mode. |
| if (!this.isAltGameModeEnabled()) { |
| if (this.invertTimer > this.config.invertFadeDuration) { |
| this.invertTimer = 0; |
| this.invertTrigger = false; |
| this.invert(false); |
| } else if (this.invertTimer) { |
| this.invertTimer += deltaTime; |
| } else { |
| const actualDistance = |
| this.distanceMeter.getActualDistance(Math.ceil(this.distanceRan)); |
| |
| if (actualDistance > 0) { |
| this.invertTrigger = !(actualDistance % this.config.invertDistance); |
| |
| if (this.invertTrigger && this.invertTimer === 0) { |
| this.invertTimer += deltaTime; |
| this.invert(false); |
| } |
| } |
| } |
| } |
| } |
| |
| if (this.playing || |
| (!this.activated && this.tRex.blinkCount < this.config.maxBlinkCount)) { |
| this.tRex.update(deltaTime); |
| this.scheduleNextUpdate(); |
| } |
| } |
| |
| handleEvent(e: Event) { |
| switch (e.type) { |
| case RunnerEvents.KEYDOWN: |
| case RunnerEvents.TOUCHSTART: |
| case RunnerEvents.POINTERDOWN: |
| this.onKeyDown(e); |
| break; |
| case RunnerEvents.KEYUP: |
| case RunnerEvents.TOUCHEND: |
| case RunnerEvents.POINTERUP: |
| this.onKeyUp(e); |
| break; |
| case RunnerEvents.GAMEPADCONNECTED: |
| this.onGamepadConnected(); |
| break; |
| default: |
| } |
| } |
| |
| /** |
| * Initialize audio cues if activated by focus on the canvas element. |
| */ |
| private handleCanvasKeyPress(e: Event) { |
| if (!this.activated && !this.hasAudioCuesInternal) { |
| this.toggleSpeed(); |
| this.hasAudioCuesInternal = true; |
| this.generatedSoundFx = new GeneratedSoundFx(); |
| this.config.clearTime *= 1.2; |
| } else if ( |
| e instanceof KeyboardEvent && runnerKeycodes.jump.includes(e.keyCode)) { |
| this.onKeyDown(e); |
| } |
| } |
| |
| /** |
| * Prevent space key press from scrolling. |
| */ |
| private preventScrolling(e: KeyboardEvent) { |
| if (e.keyCode === 32) { |
| e.preventDefault(); |
| } |
| } |
| |
| /** |
| * Toggle speed setting if toggle is shown. |
| */ |
| private toggleSpeed() { |
| if (this.hasAudioCuesInternal) { |
| assert(this.slowSpeedCheckbox); |
| const speedChange = this.hasSlowdown !== this.slowSpeedCheckbox.checked; |
| |
| if (speedChange) { |
| assert(this.horizon); |
| assert(this.tRex); |
| this.hasSlowdownInternal = this.slowSpeedCheckbox.checked; |
| const updatedConfig = |
| this.hasSlowdown ? slowModeConfig : normalModeConfig; |
| |
| this.config = Object.assign(defaultBaseConfig, updatedConfig); |
| this.currentSpeed = updatedConfig.speed; |
| this.tRex.enableSlowConfig(); |
| this.horizon.adjustObstacleSpeed(); |
| } |
| if (this.playing) { |
| this.disableSpeedToggle(true); |
| } |
| } |
| } |
| |
| /** |
| * Show the speed toggle. |
| * From focus event or when audio cues are activated. |
| */ |
| private showSpeedToggle(e?: Event) { |
| const isFocusEvent = e && e.type === 'focus'; |
| if (this.hasAudioCuesInternal || isFocusEvent) { |
| assert(this.slowSpeedCheckboxLabel); |
| this.slowSpeedCheckboxLabel.classList.toggle( |
| HIDDEN_CLASS, isFocusEvent ? false : !this.crashed); |
| } |
| } |
| |
| /** |
| * Disable the speed toggle. |
| */ |
| private disableSpeedToggle(disable: boolean) { |
| assert(this.slowSpeedCheckbox); |
| if (disable) { |
| this.slowSpeedCheckbox.setAttribute('disabled', 'disabled'); |
| } else { |
| this.slowSpeedCheckbox.removeAttribute('disabled'); |
| } |
| } |
| |
| /** |
| * Bind relevant key / mouse / touch listeners. |
| */ |
| private startListening() { |
| assert(this.containerEl); |
| assert(this.canvas); |
| // A11y keyboard / screen reader activation. |
| this.containerEl.addEventListener( |
| RunnerEvents.KEYDOWN, this.handleCanvasKeyPress.bind(this)); |
| if (!IS_MOBILE) { |
| this.containerEl.addEventListener( |
| RunnerEvents.FOCUS, this.showSpeedToggle.bind(this)); |
| } |
| this.canvas.addEventListener( |
| RunnerEvents.KEYDOWN, this.preventScrolling.bind(this)); |
| this.canvas.addEventListener( |
| RunnerEvents.KEYUP, this.preventScrolling.bind(this)); |
| |
| // Keys. |
| document.addEventListener(RunnerEvents.KEYDOWN, this); |
| document.addEventListener(RunnerEvents.KEYUP, this); |
| |
| // Touch / pointer. |
| this.containerEl.addEventListener(RunnerEvents.TOUCHSTART, this); |
| document.addEventListener(RunnerEvents.POINTERDOWN, this); |
| document.addEventListener(RunnerEvents.POINTERUP, this); |
| |
| if (this.isArcadeMode()) { |
| // Gamepad |
| window.addEventListener(RunnerEvents.GAMEPADCONNECTED, this); |
| } |
| } |
| |
| /** |
| * Process keydown. |
| */ |
| private onKeyDown(e: Event) { |
| // Prevent native page scrolling whilst tapping on mobile. |
| if (IS_MOBILE && this.playing) { |
| e.preventDefault(); |
| } |
| |
| if (this.isCanvasInView()) { |
| // Allow toggling of speed toggle. |
| if (e instanceof KeyboardEvent && |
| runnerKeycodes.jump.includes(e.keyCode) && |
| e.target === this.slowSpeedCheckbox) { |
| return; |
| } |
| |
| if (!this.crashed && !this.paused) { |
| // For a11y, screen reader activation. |
| const isMobileMouseInput = IS_MOBILE && e instanceof PointerEvent && |
| e.type === RunnerEvents.POINTERDOWN && e.pointerType === 'mouse' && |
| (e.target === this.containerEl || |
| (IS_IOS && |
| (e.target === this.touchController || e.target === this.canvas))); |
| assert(this.tRex); |
| |
| if ((e instanceof KeyboardEvent && |
| runnerKeycodes.jump.includes(e.keyCode)) || |
| e.type === RunnerEvents.TOUCHSTART || isMobileMouseInput) { |
| e.preventDefault(); |
| // Starting the game for the first time. |
| if (!this.playing) { |
| // Started by touch so create a touch controller. |
| if (!this.touchController && e.type === RunnerEvents.TOUCHSTART) { |
| this.createTouchController(); |
| } |
| |
| if (isMobileMouseInput) { |
| this.handleCanvasKeyPress(e); |
| } |
| this.loadSounds(); |
| this.setPlayStatus(true); |
| this.update(); |
| if (window.errorPageController) { |
| window.errorPageController.trackEasterEgg(); |
| } |
| } |
| // Start jump. |
| if (!this.tRex.jumping && !this.tRex.ducking) { |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx().cancelFootSteps(); |
| } else { |
| this.playSound(this.soundFx.BUTTON_PRESS); |
| } |
| this.tRex.startJump(this.currentSpeed); |
| } |
| } else if ( |
| this.playing && e instanceof KeyboardEvent && |
| runnerKeycodes.duck.includes(e.keyCode)) { |
| e.preventDefault(); |
| if (this.tRex.jumping) { |
| // Speed drop, activated only when jump key is not pressed. |
| this.tRex.setSpeedDrop(); |
| } else if (!this.tRex.jumping && !this.tRex.ducking) { |
| // Duck. |
| this.tRex.setDuck(true); |
| } |
| } |
| } |
| } |
| } |
| |
| /** |
| * Process key up. |
| */ |
| private onKeyUp(e: Event) { |
| assert(this.tRex); |
| const keyCode = ('keyCode' in e) ? e.keyCode as number : 0; |
| const isjumpKey = runnerKeycodes.jump.includes(keyCode) || |
| e.type === RunnerEvents.TOUCHEND || e.type === RunnerEvents.POINTERUP; |
| |
| if (this.isRunning() && isjumpKey) { |
| this.tRex.endJump(); |
| } else if (runnerKeycodes.duck.includes(keyCode)) { |
| this.tRex.speedDrop = false; |
| this.tRex.setDuck(false); |
| } else if (this.crashed) { |
| // Check that enough time has elapsed before allowing jump key to restart. |
| const deltaTime = getTimeStamp() - this.time; |
| |
| if (this.isCanvasInView() && |
| (runnerKeycodes.restart.includes(keyCode) || |
| this.isLeftClickOnCanvas(e) || |
| (deltaTime >= this.config.gameoverClearTime && |
| runnerKeycodes.jump.includes(keyCode)))) { |
| this.handleGameOverClicks(e); |
| } |
| } else if (this.paused && isjumpKey) { |
| // Reset the jump state |
| this.tRex.reset(); |
| this.play(); |
| } |
| } |
| |
| /** |
| * Process gamepad connected event. |
| */ |
| private onGamepadConnected() { |
| if (!this.pollingGamepads) { |
| this.pollGamepadState(); |
| } |
| } |
| |
| /** |
| * rAF loop for gamepad polling. |
| */ |
| private pollGamepadState() { |
| const gamepads: Array<Gamepad|null> = navigator.getGamepads(); |
| this.pollActiveGamepad(gamepads); |
| |
| this.pollingGamepads = true; |
| requestAnimationFrame(this.pollGamepadState.bind(this)); |
| } |
| |
| /** |
| * Polls for a gamepad with the jump button pressed. If one is found this |
| * becomes the "active" gamepad and all others are ignored. |
| */ |
| private pollForActiveGamepad(gamepads: Array<Gamepad|null>) { |
| for (const [i, gamepad] of gamepads.entries()) { |
| if (gamepad && gamepad.buttons.length > 0 && |
| gamepad.buttons[0]!.pressed) { |
| this.gamepadIndex = i; |
| this.pollActiveGamepad(gamepads); |
| return; |
| } |
| } |
| } |
| |
| /** |
| * Polls the chosen gamepad for button presses and generates KeyboardEvents |
| * to integrate with the rest of the game logic. |
| */ |
| private pollActiveGamepad(gamepads: Array<Gamepad|null>) { |
| if (this.gamepadIndex === undefined) { |
| this.pollForActiveGamepad(gamepads); |
| return; |
| } |
| |
| const gamepad = gamepads[this.gamepadIndex]; |
| if (!gamepad) { |
| this.gamepadIndex = undefined; |
| this.pollForActiveGamepad(gamepads); |
| return; |
| } |
| |
| // The gamepad specification defines the typical mapping of physical buttons |
| // to button indices: https://w3c.github.io/gamepad/#remapping |
| this.pollGamepadButton(gamepad, 0, 38); // Jump |
| if (gamepad.buttons.length >= 2) { |
| this.pollGamepadButton(gamepad, 1, 40); // Duck |
| } |
| if (gamepad.buttons.length >= 10) { |
| this.pollGamepadButton(gamepad, 9, 13); // Restart |
| } |
| |
| this.previousGamepad = gamepad; |
| } |
| |
| /** |
| * Generates a key event based on a gamepad button. |
| */ |
| private pollGamepadButton( |
| gamepad: Gamepad, buttonIndex: number, keyCode: number) { |
| const state = gamepad.buttons[buttonIndex]?.pressed || false; |
| let previousState = false; |
| if (this.previousGamepad) { |
| previousState = |
| this.previousGamepad.buttons[buttonIndex]?.pressed || false; |
| } |
| // Generate key events on the rising and falling edge of a button press. |
| if (state !== previousState) { |
| const e = new KeyboardEvent( |
| state ? RunnerEvents.KEYDOWN : RunnerEvents.KEYUP, |
| {keyCode: keyCode}); |
| document.dispatchEvent(e); |
| } |
| } |
| |
| /** |
| * Handle interactions on the game over screen state. |
| * A user is able to tap the high score twice to reset it. |
| */ |
| private handleGameOverClicks(e: Event) { |
| if (e.target !== this.slowSpeedCheckbox) { |
| assert(this.distanceMeter); |
| e.preventDefault(); |
| if (this.distanceMeter.hasClickedOnHighScore(e) && this.highestScore) { |
| if (this.distanceMeter.isHighScoreFlashing()) { |
| // Subsequent click, reset the high score. |
| this.saveHighScore(0, true); |
| this.distanceMeter.resetHighScore(); |
| } else { |
| // First click, flash the high score. |
| this.distanceMeter.startHighScoreFlashing(); |
| } |
| } else { |
| this.distanceMeter.cancelHighScoreFlashing(); |
| this.restart(); |
| } |
| } |
| } |
| |
| /** |
| * Returns whether the event was a left click on canvas. |
| * On Windows right click is registered as a click. |
| */ |
| private isLeftClickOnCanvas(e: Event): boolean { |
| if (!(e instanceof MouseEvent)) { |
| return false; |
| } |
| |
| |
| return e.button != null && e.button < 2 && |
| e.type === RunnerEvents.POINTERUP && |
| (e.target === this.canvas || |
| (IS_MOBILE && this.hasAudioCuesInternal && |
| e.target === this.containerEl)); |
| } |
| |
| /** |
| * RequestAnimationFrame wrapper. |
| */ |
| private scheduleNextUpdate() { |
| if (!this.updatePending) { |
| this.updatePending = true; |
| this.raqId = requestAnimationFrame(this.update.bind(this)); |
| } |
| } |
| |
| /** |
| * Whether the game is running. |
| */ |
| private isRunning(): boolean { |
| return !!this.raqId; |
| } |
| |
| /** |
| * Set the initial high score as stored in the user's profile. |
| */ |
| private initializeHighScore(highScore: number) { |
| assert(this.distanceMeter); |
| this.syncHighestScore = true; |
| highScore = Math.ceil(highScore); |
| if (highScore < this.highestScore) { |
| if (window.errorPageController) { |
| window.errorPageController.updateEasterEggHighScore(this.highestScore); |
| } |
| return; |
| } |
| this.highestScore = highScore; |
| this.distanceMeter.setHighScore(this.highestScore); |
| } |
| |
| /** |
| * Sets the current high score and saves to the profile if available. |
| * @param distanceRan Total distance ran. |
| * @param resetScore Whether to reset the score. |
| */ |
| private saveHighScore(distanceRan: number, resetScore?: boolean) { |
| assert(this.distanceMeter); |
| this.highestScore = Math.ceil(distanceRan); |
| this.distanceMeter.setHighScore(this.highestScore); |
| |
| // Store the new high score in the profile. |
| if (this.syncHighestScore && window.errorPageController) { |
| if (resetScore) { |
| window.errorPageController.resetEasterEggHighScore(); |
| } else { |
| window.errorPageController.updateEasterEggHighScore(this.highestScore); |
| } |
| } |
| } |
| |
| /** |
| * Game over state. |
| */ |
| private gameOver() { |
| assert(this.distanceMeter); |
| assert(this.tRex); |
| assert(this.containerEl); |
| this.playSound(this.soundFx.HIT); |
| vibrate(200); |
| |
| this.stop(); |
| this.crashed = true; |
| this.distanceMeter.achievement = false; |
| |
| this.tRex.update(100, TrexStatus.CRASHED); |
| |
| // Game over panel. |
| if (!this.gameOverPanel) { |
| const origSpriteDef = IS_HIDPI ? spriteDefinitionByType.original.hdpi : |
| spriteDefinitionByType.original.ldpi; |
| |
| if (this.canvas) { |
| if (this.isAltGameModeEnabled()) { |
| this.gameOverPanel = new GameOverPanel( |
| this.canvas, origSpriteDef.textSprite, origSpriteDef.restart, |
| this.dimensions, /* imageSpriteProvider= */ this, |
| origSpriteDef.altGameEnd, this.altGameModeActive); |
| } else { |
| this.gameOverPanel = new GameOverPanel( |
| this.canvas, origSpriteDef.textSprite, origSpriteDef.restart, |
| this.dimensions, /* imageSpriteProvider= */ this); |
| } |
| } |
| } |
| |
| assert(this.gameOverPanel); |
| this.gameOverPanel.draw(this.altGameModeActive, this.tRex); |
| |
| // Update the high score. |
| if (this.distanceRan > this.highestScore) { |
| this.saveHighScore(this.distanceRan); |
| } |
| |
| // Reset the time clock. |
| this.time = getTimeStamp(); |
| |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx().stopAll(); |
| assert(this.containerEl); |
| this.announcePhrase( |
| getA11yString(A11yStrings.GAME_OVER) |
| .replace( |
| '$1', |
| this.distanceMeter.getActualDistance(this.distanceRan) |
| .toString()) + |
| ' ' + |
| getA11yString(A11yStrings.HIGH_SCORE) |
| .replace( |
| '$1', |
| |
| this.distanceMeter.getActualDistance(this.highestScore) |
| .toString())); |
| this.containerEl.setAttribute( |
| 'title', getA11yString(A11yStrings.ARIA_LABEL)); |
| } |
| this.showSpeedToggle(); |
| this.disableSpeedToggle(false); |
| } |
| |
| private stop() { |
| this.setPlayStatus(false); |
| this.paused = true; |
| cancelAnimationFrame(this.raqId); |
| this.raqId = 0; |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx().stopAll(); |
| } |
| } |
| |
| private play() { |
| if (!this.crashed) { |
| assert(this.tRex); |
| this.setPlayStatus(true); |
| this.paused = false; |
| this.tRex.update(0, TrexStatus.RUNNING); |
| this.time = getTimeStamp(); |
| this.update(); |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx().background(); |
| } |
| } |
| } |
| |
| private restart() { |
| if (!this.raqId) { |
| assert(this.containerEl); |
| assert(this.gameOverPanel); |
| assert(this.tRex); |
| assert(this.horizon); |
| assert(this.distanceMeter); |
| this.playCount++; |
| this.runningTime = 0; |
| this.setPlayStatus(true); |
| this.toggleSpeed(); |
| this.paused = false; |
| this.crashed = false; |
| this.distanceRan = 0; |
| this.setSpeed(this.config.speed); |
| this.time = getTimeStamp(); |
| this.containerEl.classList.remove(RunnerClasses.CRASHED); |
| this.clearCanvas(); |
| this.distanceMeter.reset(); |
| this.horizon.reset(); |
| this.tRex.reset(); |
| this.playSound(this.soundFx.BUTTON_PRESS); |
| this.invert(true); |
| this.update(); |
| this.gameOverPanel.reset(); |
| if (this.hasAudioCuesInternal) { |
| this.getGeneratedSoundFx().background(); |
| } |
| this.containerEl.setAttribute('title', getA11yString(A11yStrings.JUMP)); |
| this.announcePhrase(getA11yString(A11yStrings.STARTED)); |
| } |
| } |
| |
| private setPlayStatus(isPlaying: boolean) { |
| if (this.touchController) { |
| this.touchController.classList.toggle(HIDDEN_CLASS, !isPlaying); |
| } |
| this.playing = isPlaying; |
| } |
| |
| /** |
| * Whether the game should go into arcade mode. |
| */ |
| private isArcadeMode(): boolean { |
| // In RTL languages the title is wrapped with the left to right mark |
| // control characters ‪ and ‬ but are invisible. |
| return IS_RTL ? document.title.indexOf(ARCADE_MODE_URL) === 1 : |
| document.title === ARCADE_MODE_URL; |
| } |
| |
| /** |
| * Hides offline messaging for a fullscreen game only experience. |
| */ |
| private setArcadeMode() { |
| document.body.classList.add(RunnerClasses.ARCADE_MODE); |
| this.setArcadeModeContainerScale(); |
| } |
| |
| /** |
| * Sets the scaling for arcade mode. |
| */ |
| private setArcadeModeContainerScale() { |
| assert(this.containerEl); |
| const windowHeight = window.innerHeight; |
| const scaleHeight = windowHeight / this.dimensions.height; |
| const scaleWidth = window.innerWidth / this.dimensions.width; |
| const scale = Math.max(1, Math.min(scaleHeight, scaleWidth)); |
| const scaledCanvasHeight = this.dimensions.height * scale; |
| // Positions the game container at 10% of the available vertical window |
| // height minus the game container height. |
| const translateY = Math.ceil(Math.max( |
| 0, |
| (windowHeight - scaledCanvasHeight - |
| this.config.arcadeModeInitialTopPosition) * |
| this.config.arcadeModeTopPositionPercent)) * |
| window.devicePixelRatio; |
| |
| const cssScale = IS_RTL ? -scale + ',' + scale : scale; |
| this.containerEl.style.transform = |
| 'scale(' + cssScale + ') translateY(' + translateY + 'px)'; |
| } |
| |
| /** |
| * Pause the game if the tab is not in focus. |
| */ |
| private onVisibilityChange(e: Event) { |
| if (document.hidden || e.type === 'blur' || |
| document.visibilityState !== 'visible') { |
| this.stop(); |
| } else if (!this.crashed) { |
| assert(this.tRex); |
| this.tRex.reset(); |
| this.play(); |
| } |
| } |
| |
| /** |
| * Play a sound. |
| */ |
| private playSound(soundBuffer?: AudioBuffer) { |
| if (soundBuffer) { |
| assert(this.audioContext); |
| const sourceNode = this.audioContext.createBufferSource(); |
| sourceNode.buffer = soundBuffer; |
| sourceNode.connect(this.audioContext.destination); |
| sourceNode.start(0); |
| } |
| } |
| |
| /** |
| * Inverts the current page / canvas colors. |
| * @param reset Whether to reset colors. |
| */ |
| private invert(reset: boolean) { |
| const htmlEl = document.firstElementChild; |
| assert(htmlEl); |
| |
| if (reset) { |
| htmlEl.classList.toggle(RunnerClasses.INVERTED, false); |
| this.invertTimer = 0; |
| this.inverted = false; |
| } else { |
| this.inverted = |
| htmlEl.classList.toggle(RunnerClasses.INVERTED, this.invertTrigger); |
| } |
| } |
| |
| /** |
| * For screen readers make an announcement to the live region. |
| * @param phrase Sentence to speak. |
| */ |
| private announcePhrase(phrase: string) { |
| if (this.a11yStatusEl) { |
| this.a11yStatusEl.textContent = ''; |
| this.a11yStatusEl.textContent = phrase; |
| } |
| } |
| |
| /** |
| * Check for a collision. |
| * @param obstacle Obstacle object. |
| * @param tRex T-rex object. |
| * @param canvasCtx Optional canvas context for drawing collision boxes. |
| */ |
| private checkForCollision( |
| obstacle: Obstacle, tRex: Trex, |
| canvasCtx?: CanvasRenderingContext2D): CollisionBox[]|null { |
| // Adjustments are made to the bounding box as there is a 1 pixel white |
| // border around the t-rex and obstacles. |
| const tRexBox = new CollisionBox( |
| tRex.xPos + 1, tRex.yPos + 1, tRex.config.width - 2, |
| tRex.config.height - 2); |
| |
| const obstacleBox = new CollisionBox( |
| obstacle.xPos + 1, obstacle.yPos + 1, |
| obstacle.typeConfig.width * obstacle.size - 2, |
| obstacle.typeConfig.height - 2); |
| |
| // Debug outer box |
| if (canvasCtx) { |
| drawCollisionBoxes(canvasCtx, tRexBox, obstacleBox); |
| } |
| |
| // Simple outer bounds check. |
| if (boxCompare(tRexBox, obstacleBox)) { |
| const collisionBoxes = obstacle.collisionBoxes; |
| let tRexCollisionBoxes: CollisionBox[] = []; |
| |
| if (this.isAltGameModeEnabled()) { |
| const runnerSpriteDefinition = this.getSpriteDefinition(); |
| assert(runnerSpriteDefinition); |
| assert(runnerSpriteDefinition.tRex); |
| tRexCollisionBoxes = runnerSpriteDefinition.tRex.collisionBoxes; |
| } else { |
| tRexCollisionBoxes = tRex.getCollisionBoxes(); |
| } |
| |
| // Detailed axis aligned box check. |
| for (const tRexCollisionBox of tRexCollisionBoxes) { |
| for (const obstacleCollixionBox of collisionBoxes) { |
| // Adjust the box to actual positions. |
| const adjTrexBox = |
| createAdjustedCollisionBox(tRexCollisionBox, tRexBox); |
| const adjObstacleBox = |
| createAdjustedCollisionBox(obstacleCollixionBox, obstacleBox); |
| const crashed = boxCompare(adjTrexBox, adjObstacleBox); |
| |
| // Draw boxes for debug. |
| if (canvasCtx) { |
| drawCollisionBoxes(canvasCtx, adjTrexBox, adjObstacleBox); |
| } |
| |
| if (crashed) { |
| return [adjTrexBox, adjObstacleBox]; |
| } |
| } |
| } |
| } |
| |
| return null; |
| } |
| } |
| |
| |
| /** |
| * 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/ |
| * |
| * @return Whether the canvas was scaled. |
| */ |
| function updateCanvasScaling( |
| canvas: HTMLCanvasElement, width?: number, height?: number): boolean { |
| const context = canvas.getContext('2d'); |
| assert(context); |
| |
| // Query the various pixel ratios |
| const devicePixelRatio = Math.floor(window.devicePixelRatio) || 1; |
| /** @suppress {missingProperties} */ |
| const backingStoreRatio = ('webkitBackingStorePixelRatio' in context) ? |
| Math.floor(context.webkitBackingStorePixelRatio as number) : |
| 1; |
| const ratio = devicePixelRatio / backingStoreRatio; |
| |
| // Upscale the canvas if the two ratios don't match |
| if (devicePixelRatio !== backingStoreRatio) { |
| const oldWidth = width || canvas.width; |
| const oldHeight = 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; |
| } |
| |
| |
| /** |
| * Returns a string from loadTimeData data object. |
| */ |
| function getA11yString(stringName: string): string { |
| return loadTimeData.valueExists(stringName) ? |
| loadTimeData.getString(stringName) : |
| ''; |
| } |
| |
| |
| /** |
| * Vibrate on mobile devices. |
| * @param duration Duration of the vibration in milliseconds. |
| */ |
| function vibrate(duration: number) { |
| if (IS_MOBILE && window.navigator.vibrate) { |
| window.navigator.vibrate(duration); |
| } |
| } |
| |
| |
| /** |
| * Create canvas element. |
| * @param container Element to append canvas to. |
| */ |
| function createCanvas( |
| container: Element, width: number, height: number, |
| classname?: string): HTMLCanvasElement { |
| const canvas = document.createElement('canvas'); |
| canvas.className = |
| classname ? RunnerClasses.CANVAS + ' ' + classname : RunnerClasses.CANVAS; |
| canvas.width = width; |
| canvas.height = height; |
| container.appendChild(canvas); |
| |
| return canvas; |
| } |
| |
| |
| /** |
| * Decodes the base 64 audio to ArrayBuffer used by Web Audio. |
| */ |
| function decodeBase64ToArrayBuffer(base64String: string): ArrayBuffer { |
| const len = (base64String.length / 4) * 3; |
| const str = atob(base64String); |
| const arrayBuffer = new ArrayBuffer(len); |
| const bytes = new Uint8Array(arrayBuffer); |
| |
| for (let i = 0; i < len; i++) { |
| bytes[i] = str.charCodeAt(i); |
| } |
| return bytes.buffer; |
| } |
| |
| |
| |
| //****************************************************************************** |
| |
| |
| /** |
| * Adjust the collision box. |
| * @param box The original box. |
| * @param adjustment Adjustment box. |
| * @return The adjusted collision box object. |
| */ |
| function createAdjustedCollisionBox( |
| box: CollisionBox, adjustment: CollisionBox): CollisionBox { |
| return new CollisionBox( |
| box.x + adjustment.x, box.y + adjustment.y, box.width, box.height); |
| } |
| |
| |
| /** |
| * Draw the collision boxes for debug. |
| */ |
| function drawCollisionBoxes( |
| canvasCtx: CanvasRenderingContext2D, tRexBox: CollisionBox, |
| obstacleBox: CollisionBox) { |
| 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. |
| * @return Whether the boxes intersected. |
| */ |
| function boxCompare(tRexBox: CollisionBox, obstacleBox: CollisionBox): boolean { |
| const tRexBoxX = tRexBox.x; |
| const tRexBoxY = tRexBox.y; |
| |
| const obstacleBoxX = obstacleBox.x; |
| const obstacleBoxY = obstacleBox.y; |
| |
| // Axis-Aligned Bounding Box method. |
| if (tRexBoxX < obstacleBoxX + obstacleBox.width && |
| tRexBoxX + tRexBox.width > obstacleBoxX && |
| tRexBoxY < obstacleBoxY + obstacleBox.height && |
| tRexBox.height + tRexBoxY > obstacleBoxY) { |
| return true; |
| } |
| |
| return false; |
| } |