├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc ├── README.md ├── ScreenFlow.gif ├── genetic-neural-network.html ├── genetic.html ├── index.html ├── neural-network-multiplayer.html ├── neural-network.html ├── package.json ├── random.html ├── src ├── ai │ ├── models │ │ ├── Model.js │ │ ├── genetic-nn │ │ │ └── NNModel.js │ │ ├── genetic │ │ │ ├── GeneticModel.js │ │ │ └── RandomModel.js │ │ ├── nn │ │ │ └── NNModel.js │ │ └── random │ │ │ └── RandomModel.js │ └── utils │ │ └── index.js ├── apps │ ├── genetic-nn.js │ ├── genetic.js │ ├── nn.js │ ├── nnm.js │ ├── random.js │ └── template.js └── game │ ├── Cloud.js │ ├── CollisionBox.js │ ├── DistanceMeter.js │ ├── Horizon.js │ ├── HorizonLine.js │ ├── ImageSprite.js │ ├── Obstacle.js │ ├── Runner.js │ ├── RuntimeConfig.js │ ├── Trex.js │ ├── TrexGroup.js │ ├── constants.js │ ├── images │ ├── offline-sprite.png │ └── splash.png │ ├── index.js │ ├── index.less │ └── utils.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "stage-1"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb-base", 5 | "airbnb-base/rules/strict", 6 | "prettier" 7 | ], 8 | "env": { 9 | "browser": true 10 | }, 11 | "rules": { 12 | "class-methods-use-this": "off", 13 | "comma-dangle": "off", 14 | "function-paren-newline": "off", 15 | "global-require": "off", 16 | "import/prefer-default-export": "off", 17 | "max-len": "warn", 18 | "no-loop-func": "off", 19 | "no-mixed-operators": "off", 20 | "no-param-reassign": "off", 21 | "no-unused-vars": "off", 22 | "no-use-before-define": "off", 23 | "no-console": [ 24 | "warn", 25 | { 26 | "allow": [ 27 | "info", 28 | "warn", 29 | "error" 30 | ] 31 | } 32 | ], 33 | "no-underscore-dangle": "off", 34 | "prefer-destructuring": "off" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Compiled assets 9 | build 10 | 11 | # Dependency directories 12 | node_modules/ 13 | 14 | # Optional npm cache directory 15 | .npm 16 | 17 | # Optional eslint cache 18 | .eslintcache 19 | 20 | # VSCode 21 | .vscode 22 | 23 | # Package 24 | package-lock.json 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "arrowParens": "always" 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tensorflow-rex-run 2 | 3 | A TensorFlow.js based AI player platform for T-Rex Runner. 4 | 5 | ## About This Project (Chinese) 6 | [神经网络与遗传算法: TensorFlow.js 学会游戏通关](https://zhuanlan.zhihu.com/p/35451395) 7 | 8 | ## About T-Rex Runner Game 9 | 10 | ![](https://9to5google.files.wordpress.com/2015/06/pterodactyl.png?w=1600&h=1000) 11 | 12 | [T-Rex Runner](http://www.omgchrome.com/chrome-easter-egg-trex-game-offline/) is [originally](https://cs.chromium.org/chromium/src/components/neterror/resources/offline.js?q=t-rex+package:%5Echromium$&dr=C&l=7) an easter egg game inside chrome's offline error page. 13 | 14 | 15 | ## About TensorFlow.js 16 | 17 | The official version of TensorFlow in JavaScript. It is A WebGL accelerated, browser based JavaScript library for training and deploying ML models. 18 | Visit the [official website](https://js.tensorflow.org/) to discover more. 19 | 20 | 21 | ## About This Project 22 | 23 | `t-trex-run` is an artificial intelligent player platform designed for T-Rex Runner, and enpowered by TensorFlow.js. 24 | 25 | ![](https://github.com/MagicCube/t-rex-run/blob/master/ScreenFlow.gif?raw=true) 26 | 27 | ## Key Features 28 | 29 | * Totally rewritten in ES6/ES7, LESS and Webpack 30 | * Multiplayer mode supported (means you can use genetic algorithm now) 31 | * Events like `onReset`, `onRunning` and `onCrushed` are supported 32 | * Example models are provided 33 | 34 | 35 | --- 36 | 37 | 38 | ## How to Install 39 | 40 | ```sh 41 | npm install 42 | ``` 43 | 44 | 45 | ## How to Run 46 | 47 | 48 | ```sh 49 | npm start 50 | ``` 51 | 52 | Visit http://localhost:8080 53 | -------------------------------------------------------------------------------- /ScreenFlow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MagicCube/tensorflow-rex-run/eb0ea3658d7f959e41337aacf452f85fc608f12b/ScreenFlow.gif -------------------------------------------------------------------------------- /genetic-neural-network.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Genetic Algorithm - T-Rex Runner 8 | 9 | 10 | 11 |

Genetic Algorithm Model

12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /genetic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Genetic Algorithm - T-Rex Runner 8 | 9 | 10 | 11 |

Genetic Algorithm

12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Welcome to T-Rex Run 8 | 9 | 10 |

Welcome to T-Rex Run

11 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /neural-network-multiplayer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Neural Network for Multiplayers - T-Rex Runner 8 | 9 | 10 | 11 |

Neural Network for Multiplayers

12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /neural-network.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Neural Network - T-Rex Runner 8 | 9 | 10 | 11 |

Simple Neural Network Model

12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "t-rex-run", 3 | "version": "1.0.0", 4 | "description": "A TensorFlow.js based AI player platform for T-Rex Runner. T-Rex Runner is originally an easter egg game inside chrome.", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "start": "webpack-dev-server --mode development" 9 | }, 10 | "keywords": [ 11 | "tensorflow", 12 | "tensorflow.js", 13 | "neural network", 14 | "ai", 15 | "t-rex", 16 | "runner", 17 | "chrome", 18 | "easter", 19 | "egg", 20 | "multiplayer", 21 | "game" 22 | ], 23 | "author": "Henry Li ", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "babel-core": "^6.26.0", 27 | "babel-eslint": "^8.2.2", 28 | "babel-loader": "^7.1.4", 29 | "babel-preset-env": "^1.6.1", 30 | "babel-preset-stage-1": "^6.24.1", 31 | "clean-webpack-plugin": "^0.1.19", 32 | "css-loader": "^0.28.11", 33 | "eslint": "^4.19.1", 34 | "eslint-config-airbnb-base": "^12.1.0", 35 | "eslint-config-prettier": "^2.9.0", 36 | "eslint-loader": "^2.0.0", 37 | "eslint-plugin-import": "^2.10.0", 38 | "file-loader": "^1.1.11", 39 | "less": "^3.0.1", 40 | "less-loader": "^4.1.0", 41 | "style-loader": "^0.20.3", 42 | "url-loader": "^1.0.1", 43 | "webpack": "^4.5.0", 44 | "webpack-cli": "^3.3.12", 45 | "webpack-dev-server": "^3.1.3" 46 | }, 47 | "dependencies": { 48 | "@tensorflow/tfjs": "^0.9.0", 49 | "babel-polyfill": "^6.26.0", 50 | "package.json": "^2.0.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /random.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Random - T-Rex Runner 8 | 9 | 10 | 11 |

Random for Fun

12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/ai/models/Model.js: -------------------------------------------------------------------------------- 1 | import { tensor } from '../utils'; 2 | 3 | export default class Model { 4 | init() { 5 | throw new Error( 6 | 'Abstract method must be implemented in the derived class.' 7 | ); 8 | } 9 | 10 | predict(inputXs) { 11 | throw new Error( 12 | 'Abstract method must be implemented in the derived class.' 13 | ); 14 | } 15 | 16 | predictSingle(inputX) { 17 | return this.predict([inputX]); 18 | } 19 | 20 | train(inputXs, inputYs) { 21 | throw new Error( 22 | 'Abstract method must be implemented in the derived class.' 23 | ); 24 | } 25 | 26 | fit(inputXs, inputYs, iterationCount = 100) { 27 | for (let i = 0; i < iterationCount; i += 1) { 28 | this.train(inputXs, inputYs); 29 | } 30 | } 31 | 32 | loss(predictedYs, labels) { 33 | const meanSquareError = predictedYs 34 | .sub(tensor(labels)) 35 | .square() 36 | .mean(); 37 | return meanSquareError; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ai/models/genetic-nn/NNModel.js: -------------------------------------------------------------------------------- 1 | import * as tf from '@tensorflow/tfjs'; 2 | import NNModel from '../nn/NNModel'; 3 | 4 | export default class GeneticNNModel extends NNModel { 5 | getChromosome() { 6 | const result = tf.concat([ 7 | this.weights[0].flatten(), 8 | this.biases[0].flatten(), 9 | this.weights[1].flatten(), 10 | this.biases[1].flatten() 11 | ]); 12 | return result.dataSync(); 13 | } 14 | 15 | setChromosome(chromosome) { 16 | let weight = chromosome.slice(0, 3 * 6); 17 | let bias = chromosome.slice(3 * 6, 3 * 6 + 1); 18 | this.weights[0].assign(tf.tensor(weight, [3, 6])); 19 | this.biases[0].assign(tf.tensor(bias[0])); 20 | weight = chromosome.slice(3 * 6 + 1, 3 * 6 + 1 + 6 * 2); 21 | bias = chromosome.slice(3 * 6 + 1 + 6 * 2, 3 * 6 + 1 + 6 * 2 + 1); 22 | this.weights[1].assign(tf.tensor(weight, [6, 2])); 23 | this.biases[1].assign(tf.tensor(bias[0])); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ai/models/genetic/GeneticModel.js: -------------------------------------------------------------------------------- 1 | import Model from '../Model'; 2 | 3 | export default class GeneticModel extends Model { 4 | train(chromosomes) { 5 | const parents = this.select(chromosomes); 6 | const offspring = this.crossOver(parents, chromosomes); 7 | this.mutate(offspring); 8 | } 9 | 10 | fit(chromosomes) { 11 | this.train(chromosomes); 12 | } 13 | 14 | select(chromosomes) { 15 | const parents = [chromosomes[0], chromosomes[1]]; 16 | return parents; 17 | } 18 | 19 | crossOver(parents, chromosomes) { 20 | // Clone from parents 21 | const offspring1 = parents[0].slice(); 22 | const offspring2 = parents[1].slice(); 23 | // Select a random crossover point 24 | const crossOverPoint = Math.floor(Math.random() * chromosomes.length); 25 | // Swap values among parents 26 | for (let i = 0; i < crossOverPoint; i += 1) { 27 | const temp = offspring1[i]; 28 | offspring1[i] = offspring2[i]; 29 | offspring2[i] = temp; 30 | } 31 | const offspring = [offspring1, offspring2]; 32 | // Replace the last 2 with the new offspring 33 | for (let i = 0; i < 2; i += 1) { 34 | chromosomes[chromosomes.length - i - 1] = offspring[i]; 35 | } 36 | return offspring; 37 | } 38 | 39 | mutate(chromosomes) { 40 | chromosomes.forEach(chromosome => { 41 | const mutationPoint = Math.floor(Math.random() * chromosomes.length); 42 | chromosome[mutationPoint] = Math.random(); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ai/models/genetic/RandomModel.js: -------------------------------------------------------------------------------- 1 | import RandomModel from '../random/RandomModel'; 2 | 3 | export default class GeneticRandomModel extends RandomModel { 4 | getChromosome() { 5 | return this.weights.concat(this.biases); 6 | } 7 | 8 | setChromosome(chromosome) { 9 | this.weights[0] = chromosome[0]; 10 | this.weights[1] = chromosome[1]; 11 | this.weights[2] = chromosome[2]; 12 | this.biases[0] = chromosome[3]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ai/models/nn/NNModel.js: -------------------------------------------------------------------------------- 1 | import * as tf from '@tensorflow/tfjs'; 2 | 3 | import { tensor } from '../../utils'; 4 | import Model from '../Model'; 5 | 6 | /** 7 | * Simple Neural Network Model 8 | */ 9 | export default class NNModel extends Model { 10 | weights = []; 11 | biases = []; 12 | 13 | constructor({ 14 | inputSize = 3, 15 | hiddenLayerSize = inputSize * 2, 16 | outputSize = 2, 17 | learningRate = 0.1 18 | } = {}) { 19 | super(); 20 | this.hiddenLayerSize = hiddenLayerSize; 21 | this.inputSize = inputSize; 22 | this.outputSize = outputSize; 23 | // Using ADAM optimizer 24 | this.optimizer = tf.train.adam(learningRate); 25 | } 26 | 27 | init() { 28 | // Hidden layer 29 | this.weights[0] = tf.variable( 30 | tf.randomNormal([this.inputSize, this.hiddenLayerSize]) 31 | ); 32 | this.biases[0] = tf.variable(tf.scalar(Math.random())); 33 | // Output layer 34 | this.weights[1] = tf.variable( 35 | tf.randomNormal([this.hiddenLayerSize, this.outputSize]) 36 | ); 37 | this.biases[1] = tf.variable(tf.scalar(Math.random())); 38 | } 39 | 40 | predict(inputXs) { 41 | const x = tensor(inputXs); 42 | const prediction = tf.tidy(() => { 43 | const hiddenLayer = tf.sigmoid(x.matMul(this.weights[0]).add(this.biases[0])); 44 | const outputLayer = tf.sigmoid(hiddenLayer.matMul(this.weights[1]).add(this.biases[1])); 45 | return outputLayer; 46 | }); 47 | return prediction; 48 | } 49 | 50 | train(inputXs, inputYs) { 51 | this.optimizer.minimize(() => { 52 | const predictedYs = this.predict(inputXs); 53 | return this.loss(predictedYs, inputYs); 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ai/models/random/RandomModel.js: -------------------------------------------------------------------------------- 1 | import Model from '../Model'; 2 | 3 | export default class RandomModel extends Model { 4 | weights = []; 5 | biases = []; 6 | 7 | init() { 8 | this.randomize(); 9 | } 10 | 11 | predict(inputXs) { 12 | const inputX = inputXs[0]; 13 | const y = 14 | this.weights[0] * inputX[0] + 15 | this.weights[1] * inputX[1]+ 16 | this.weights[2] * inputX[2] + 17 | this.biases[0]; 18 | return y < 0 ? 1 : 0; 19 | } 20 | 21 | train() { 22 | this.randomize(); 23 | } 24 | 25 | randomize() { 26 | this.weights[0] = random(); 27 | this.weights[1] = random(); 28 | this.weights[2] = random(); 29 | this.biases[0] = random(); 30 | } 31 | } 32 | 33 | function random() { 34 | return (Math.random() - 0.5) * 2; 35 | } 36 | -------------------------------------------------------------------------------- /src/ai/utils/index.js: -------------------------------------------------------------------------------- 1 | import * as tf from '@tensorflow/tfjs'; 2 | 3 | export function isTensor(obj) { 4 | return obj instanceof tf.Tensor; 5 | } 6 | 7 | export function tensor(obj) { 8 | if (obj instanceof tf.Tensor) { 9 | return obj; 10 | } 11 | if (typeof obj === 'number') { 12 | return tf.scalar(obj); 13 | } else if (Array.isArray(obj)) { 14 | return tf.tensor(obj); 15 | } 16 | throw new Error( 17 | 'tensor() only supports number or array as the input parameter.' 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/apps/genetic-nn.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../game/constants'; 4 | import { Runner } from '../game'; 5 | import NNModel from '../ai/models/genetic-nn/NNModel'; 6 | import GeneticModel from '../ai/models/genetic/GeneticModel'; 7 | 8 | const T_REX_COUNT = 10; 9 | 10 | const geneticModel = new GeneticModel(); 11 | const rankList = []; 12 | 13 | let runner = null; 14 | 15 | function setup() { 16 | // Initialize the game Runner. 17 | runner = new Runner('.game', { 18 | T_REX_COUNT, 19 | onReset: handleReset, 20 | onCrash: handleCrash, 21 | onRunning: handleRunning 22 | }); 23 | // Set runner as a global variable if you need runtime debugging. 24 | window.runner = runner; 25 | // Initialize everything in the game and start the game. 26 | runner.init(); 27 | } 28 | 29 | let firstTime = true; 30 | function handleReset({ tRexes }) { 31 | if (firstTime) { 32 | // Initialize all the tRexes for the very first time. 33 | firstTime = false; 34 | tRexes.forEach((tRex) => { 35 | tRex.model = new NNModel(); 36 | tRex.model.init(); 37 | tRex.training = { 38 | inputs: [], 39 | labels: [] 40 | }; 41 | }); 42 | } else { 43 | // Train the model before restarting. 44 | console.info('Training'); 45 | // Do the NN training first 46 | tRexes.forEach((tRex) => { 47 | tRex.model.fit(tRex.training.inputs, tRex.training.labels); 48 | }); 49 | // Genetic training 50 | const chromosomes = rankList.map((tRex) => tRex.model.getChromosome()); 51 | // Clear rankList 52 | rankList.splice(0); 53 | geneticModel.fit(chromosomes); 54 | tRexes.forEach((tRex, i) => { 55 | tRex.model.setChromosome(chromosomes[i]); 56 | }); 57 | } 58 | } 59 | 60 | function handleRunning({ tRex, state }) { 61 | return new Promise((resolve) => { 62 | if (!tRex.jumping) { 63 | let action = 0; 64 | const prediction = tRex.model.predictSingle(convertStateToVector(state)); 65 | prediction.data().then((result) => { 66 | if (result[1] > result[0]) { 67 | action = 1; 68 | tRex.lastJumpingState = state; 69 | } else { 70 | tRex.lastRunningState = state; 71 | } 72 | resolve(action); 73 | }); 74 | } else { 75 | resolve(0); 76 | } 77 | }); 78 | } 79 | 80 | function handleCrash({ tRex }) { 81 | let input = null; 82 | let label = null; 83 | if (tRex.jumping) { 84 | input = convertStateToVector(tRex.lastJumpingState); 85 | label = [1, 0]; 86 | } else { 87 | input = convertStateToVector(tRex.lastRunningState); 88 | label = [0, 1]; 89 | } 90 | tRex.training.inputs.push(input); 91 | tRex.training.labels.push(label); 92 | if (!rankList.includes(tRex)) { 93 | rankList.unshift(tRex); 94 | } 95 | } 96 | 97 | function convertStateToVector(state) { 98 | if (state) { 99 | return [ 100 | state.obstacleX / CANVAS_WIDTH, 101 | state.obstacleWidth / CANVAS_WIDTH, 102 | state.speed / 100 103 | ]; 104 | } 105 | return [0, 0, 0]; 106 | } 107 | 108 | document.addEventListener('DOMContentLoaded', setup); 109 | -------------------------------------------------------------------------------- /src/apps/genetic.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../game/constants'; 4 | import { Runner } from '../game'; 5 | import GeneticModel from '../ai/models/genetic/GeneticModel'; 6 | import RandomModel from '../ai/models/genetic/RandomModel'; 7 | 8 | const T_REX_COUNT = 10; 9 | 10 | let runner = null; 11 | 12 | const rankList = []; 13 | const geneticModel = new GeneticModel(); 14 | 15 | function setup() { 16 | // Initialize the game Runner. 17 | runner = new Runner('.game', { 18 | T_REX_COUNT, 19 | onReset: handleReset, 20 | onCrash: handleCrash, 21 | onRunning: handleRunning 22 | }); 23 | // Set runner as a global variable if you need runtime debugging. 24 | window.runner = runner; 25 | // Initialize everything in the game and start the game. 26 | runner.init(); 27 | } 28 | 29 | let firstTime = true; 30 | function handleReset({ tRexes }) { 31 | if (firstTime) { 32 | // Initialize all the tRexes with random models 33 | // for the very first time. 34 | firstTime = false; 35 | tRexes.forEach((tRex) => { 36 | tRex.model = new RandomModel(); 37 | tRex.model.init(); 38 | }); 39 | } else { 40 | // Train the model before restarting. 41 | console.info('Training'); 42 | const chromosomes = rankList.map((tRex) => tRex.model.getChromosome()); 43 | // Clear rankList 44 | rankList.splice(0); 45 | geneticModel.fit(chromosomes); 46 | tRexes.forEach((tRex, i) => { 47 | tRex.model.setChromosome(chromosomes[i]); 48 | }); 49 | } 50 | } 51 | 52 | function handleRunning({ tRex, state }) { 53 | let action = 0; 54 | if (!tRex.jumping) { 55 | action = tRex.model.predictSingle(convertStateToVector(state)); 56 | } 57 | return action; 58 | } 59 | 60 | function handleCrash({ tRex }) { 61 | if (!rankList.includes(tRex)) { 62 | rankList.unshift(tRex); 63 | } 64 | } 65 | 66 | function convertStateToVector(state) { 67 | if (state) { 68 | return [ 69 | state.obstacleX / CANVAS_WIDTH, 70 | state.obstacleWidth / CANVAS_WIDTH, 71 | state.speed / 100 72 | ]; 73 | } 74 | return [0, 0, 0]; 75 | } 76 | 77 | document.addEventListener('DOMContentLoaded', setup); 78 | -------------------------------------------------------------------------------- /src/apps/nn.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../game/constants'; 4 | import { Runner } from '../game'; 5 | import NNModel from '../ai/models/nn/NNModel'; 6 | 7 | let runner = null; 8 | 9 | function setup() { 10 | // Initialize the game Runner. 11 | runner = new Runner('.game', { 12 | T_REX_COUNT: 1, 13 | onReset: handleReset, 14 | onCrash: handleCrash, 15 | onRunning: handleRunning 16 | }); 17 | // Set runner as a global variable if you need runtime debugging. 18 | window.runner = runner; 19 | // Initialize everything in the game and start the game. 20 | runner.init(); 21 | } 22 | 23 | let firstTime = true; 24 | function handleReset({ tRexes }) { 25 | const tRex = tRexes[0]; 26 | if (firstTime) { 27 | firstTime = false; 28 | tRex.model = new NNModel(); 29 | tRex.model.init(); 30 | tRex.training = { 31 | inputs: [], 32 | labels: [] 33 | }; 34 | } else { 35 | // Train the model before restarting. 36 | console.info('Training'); 37 | tRex.model.fit(tRex.training.inputs, tRex.training.labels); 38 | } 39 | } 40 | 41 | function handleRunning({ tRex, state }) { 42 | return new Promise((resolve) => { 43 | if (!tRex.jumping) { 44 | let action = 0; 45 | const prediction = tRex.model.predictSingle(convertStateToVector(state)); 46 | prediction.data().then((result) => { 47 | if (result[1] > result[0]) { 48 | action = 1; 49 | tRex.lastJumpingState = state; 50 | } else { 51 | tRex.lastRunningState = state; 52 | } 53 | resolve(action); 54 | }); 55 | } else { 56 | resolve(0); 57 | } 58 | }); 59 | } 60 | 61 | function handleCrash({ tRex }) { 62 | let input = null; 63 | let label = null; 64 | if (tRex.jumping) { 65 | // Should not jump next time 66 | input = convertStateToVector(tRex.lastJumpingState); 67 | label = [1, 0]; 68 | } else { 69 | // Should jump next time 70 | input = convertStateToVector(tRex.lastRunningState); 71 | label = [0, 1]; 72 | } 73 | tRex.training.inputs.push(input); 74 | tRex.training.labels.push(label); 75 | } 76 | 77 | function convertStateToVector(state) { 78 | if (state) { 79 | return [ 80 | state.obstacleX / CANVAS_WIDTH, 81 | state.obstacleWidth / CANVAS_WIDTH, 82 | state.speed / 100 83 | ]; 84 | } 85 | return [0, 0, 0]; 86 | } 87 | 88 | document.addEventListener('DOMContentLoaded', setup); 89 | -------------------------------------------------------------------------------- /src/apps/nnm.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../game/constants'; 4 | import { Runner } from '../game'; 5 | import NNModel from '../ai/models/nn/NNModel'; 6 | 7 | const T_REX_COUNT = 3; 8 | 9 | let runner = null; 10 | 11 | const training = { 12 | inputs: [], 13 | labels: [] 14 | }; 15 | 16 | function setup() { 17 | // Initialize the game Runner. 18 | runner = new Runner('.game', { 19 | T_REX_COUNT, 20 | onReset: handleReset, 21 | onCrash: handleCrash, 22 | onRunning: handleRunning 23 | }); 24 | // Set runner as a global variable if you need runtime debugging. 25 | window.runner = runner; 26 | // Initialize everything in the game and start the game. 27 | runner.init(); 28 | } 29 | 30 | let firstTime = true; 31 | function handleReset({ tRexes }) { 32 | if (firstTime) { 33 | // Initialize all the tRexes for the very first time. 34 | firstTime = false; 35 | tRexes.forEach((tRex) => { 36 | tRex.model = new NNModel(); 37 | tRex.model.init(); 38 | }); 39 | } else { 40 | // Train the model before restarting. 41 | console.info('Training'); 42 | tRexes.forEach((tRex) => { 43 | tRex.model.fit(training.inputs, training.labels); 44 | }); 45 | } 46 | } 47 | 48 | function handleRunning({ tRex, state }) { 49 | return new Promise((resolve) => { 50 | if (!tRex.jumping) { 51 | let action = 0; 52 | const prediction = tRex.model.predictSingle(convertStateToVector(state)); 53 | prediction.data().then((result) => { 54 | if (result[1] > result[0]) { 55 | action = 1; 56 | tRex.lastJumpingState = state; 57 | } else { 58 | tRex.lastRunningState = state; 59 | } 60 | resolve(action); 61 | }); 62 | } else { 63 | resolve(0); 64 | } 65 | }); 66 | } 67 | 68 | function handleCrash({ tRex }) { 69 | let input = null; 70 | let label = null; 71 | if (tRex.jumping) { 72 | input = convertStateToVector(tRex.lastJumpingState); 73 | label = [1, 0]; 74 | } else { 75 | input = convertStateToVector(tRex.lastRunningState); 76 | label = [0, 1]; 77 | } 78 | training.inputs.push(input); 79 | training.labels.push(label); 80 | } 81 | 82 | function convertStateToVector(state) { 83 | if (state) { 84 | return [ 85 | state.obstacleX / CANVAS_WIDTH, 86 | state.obstacleWidth / CANVAS_WIDTH, 87 | state.speed / 100 88 | ]; 89 | } 90 | return [0, 0, 0]; 91 | } 92 | 93 | document.addEventListener('DOMContentLoaded', setup); 94 | -------------------------------------------------------------------------------- /src/apps/random.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../game/constants'; 4 | import { Runner } from '../game'; 5 | import RandomModel from '../ai/models/random/RandomModel'; 6 | 7 | let runner = null; 8 | 9 | function setup() { 10 | // Initialize the game Runner. 11 | runner = new Runner('.game', { 12 | T_REX_COUNT: 10, 13 | onReset: handleReset, 14 | onCrash: handleCrash, 15 | onRunning: handleRunning 16 | }); 17 | // Set runner as a global variable if you need runtime debugging. 18 | window.runner = runner; 19 | // Initialize everything in the game and start the game. 20 | runner.init(); 21 | } 22 | 23 | let firstTime = true; 24 | function handleReset({ tRexes }) { 25 | if (firstTime) { 26 | firstTime = false; 27 | tRexes.forEach((tRex) => { 28 | if (!tRex.model) { 29 | // Initialize all the tRexes with random models 30 | // for the very first time. 31 | tRex.model = new RandomModel(); 32 | tRex.model.init(); 33 | } 34 | }); 35 | } 36 | } 37 | 38 | function handleRunning({ tRex, state }) { 39 | let action = 0; 40 | if (!tRex.jumping) { 41 | action = tRex.model.predictSingle(convertStateToVector(state)); 42 | } 43 | return action; 44 | } 45 | 46 | function handleCrash({ tRex }) { 47 | tRex.model.train(); 48 | } 49 | 50 | function convertStateToVector(state) { 51 | if (state) { 52 | return [ 53 | state.obstacleX / CANVAS_WIDTH, 54 | state.obstacleWidth / CANVAS_WIDTH, 55 | state.speed / 100 56 | ]; 57 | } 58 | return [0, 0, 0]; 59 | } 60 | 61 | document.addEventListener('DOMContentLoaded', setup); 62 | -------------------------------------------------------------------------------- /src/apps/template.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../game/constants'; 4 | import { Runner } from '../game'; 5 | 6 | let runner = null; 7 | 8 | function setup() { 9 | // Initialize the game Runner. 10 | runner = new Runner('.game', { 11 | onReset: handleReset, 12 | onRunning: handleRunning, 13 | onCrash: handleCrash 14 | }); 15 | // Set runner as a global variable if you need runtime debugging. 16 | window.runner = runner; 17 | // Initialize everything in the game and start the game. 18 | runner.init(); 19 | } 20 | 21 | function handleReset({ tRexes }) { 22 | // Add initialization of tRexes here. 23 | // This method is called everytime the game restarts. 24 | } 25 | 26 | function handleRunning({ tRex, state }) { 27 | // Decide whether this `tRex` should jump(return 1) or keep walking(return 0) 28 | // based on the `state` provided in the parameter. 29 | } 30 | 31 | function handleCrash({ tRex }) { 32 | // Fires when the `tRex` hit a obstacle(like cactus or bird). 33 | } 34 | 35 | function convertStateToVector(state) { 36 | // Here's an example of how to convert the state which provided in handleRunning() 37 | // into a three-dimensional vector as a array. 38 | if (state) { 39 | return [ 40 | state.obstacleX / CANVAS_WIDTH, 41 | state.obstacleWidth / CANVAS_WIDTH, 42 | state.speed / 100 43 | ]; 44 | } 45 | return [0, 0, 0]; 46 | } 47 | 48 | document.addEventListener('DOMContentLoaded', setup); 49 | -------------------------------------------------------------------------------- /src/game/Cloud.js: -------------------------------------------------------------------------------- 1 | import { getImageSprite } from './ImageSprite'; 2 | import { getRandomNum } from './utils'; 3 | 4 | /** 5 | * Cloud background item. 6 | * Similar to an obstacle object but without collision boxes. 7 | * @param {HTMLCanvasElement} canvas Canvas element. 8 | * @param {Object} spritePos Position of image in sprite. 9 | * @param {number} containerWidth 10 | */ 11 | export default class Cloud { 12 | /** 13 | * Cloud object config. 14 | * @enum {number} 15 | */ 16 | static config = { 17 | HEIGHT: 14, 18 | MAX_CLOUD_GAP: 400, 19 | MAX_SKY_LEVEL: 30, 20 | MIN_CLOUD_GAP: 100, 21 | MIN_SKY_LEVEL: 71, 22 | WIDTH: 46 23 | }; 24 | 25 | constructor(canvas, spritePos, containerWidth) { 26 | this.canvas = canvas; 27 | this.canvasCtx = this.canvas.getContext('2d'); 28 | this.spritePos = spritePos; 29 | this.containerWidth = containerWidth; 30 | this.xPos = containerWidth; 31 | this.yPos = 0; 32 | this.remove = false; 33 | this.cloudGap = getRandomNum( 34 | Cloud.config.MIN_CLOUD_GAP, 35 | Cloud.config.MAX_CLOUD_GAP 36 | ); 37 | 38 | this.init(); 39 | } 40 | 41 | /** 42 | * Initialise the cloud. Sets the Cloud height. 43 | */ 44 | init() { 45 | this.yPos = getRandomNum( 46 | Cloud.config.MAX_SKY_LEVEL, 47 | Cloud.config.MIN_SKY_LEVEL 48 | ); 49 | this.draw(); 50 | } 51 | 52 | /** 53 | * Draw the cloud. 54 | */ 55 | draw() { 56 | this.canvasCtx.save(); 57 | const sourceWidth = Cloud.config.WIDTH; 58 | const sourceHeight = Cloud.config.HEIGHT; 59 | 60 | this.canvasCtx.drawImage( 61 | getImageSprite(), 62 | this.spritePos.x, 63 | this.spritePos.y, 64 | sourceWidth, 65 | sourceHeight, 66 | this.xPos, 67 | this.yPos, 68 | Cloud.config.WIDTH, 69 | Cloud.config.HEIGHT 70 | ); 71 | 72 | this.canvasCtx.restore(); 73 | } 74 | 75 | /** 76 | * Update the cloud position. 77 | * @param {number} speed 78 | */ 79 | update(speed) { 80 | if (!this.remove) { 81 | this.xPos -= Math.ceil(speed); 82 | this.draw(); 83 | 84 | // Mark as removeable if no longer in the canvas. 85 | if (!this.isVisible()) { 86 | this.remove = true; 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * Check if the cloud is visible on the stage. 93 | * @return {boolean} 94 | */ 95 | isVisible() { 96 | return this.xPos + Cloud.config.WIDTH > 0; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/game/CollisionBox.js: -------------------------------------------------------------------------------- 1 | import { CANVAS_WIDTH } from './constants'; 2 | import { getImageSprite } from './ImageSprite'; 3 | 4 | /** 5 | * Collision box object. 6 | * @param {number} x X position. 7 | * @param {number} y Y Position. 8 | * @param {number} w Width. 9 | * @param {number} h Height. 10 | */ 11 | export default class CollisionBox { 12 | constructor(x, y, w, h) { 13 | this.x = x; 14 | this.y = y; 15 | this.width = w; 16 | this.height = h; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/game/DistanceMeter.js: -------------------------------------------------------------------------------- 1 | import { getImageSprite } from './ImageSprite'; 2 | 3 | /** 4 | * Handles displaying the distance meter. 5 | * @param {!HTMLCanvasElement} canvas 6 | * @param {Object} spritePos Image position in sprite. 7 | * @param {number} canvasWidth 8 | * @constructor 9 | */ 10 | export default class DistanceMeter { 11 | static dimensions = { 12 | WIDTH: 10, 13 | HEIGHT: 13, 14 | DEST_WIDTH: 11 15 | }; 16 | 17 | static yPos = [0, 13, 27, 40, 53, 67, 80, 93, 107, 120]; 18 | 19 | static config = { 20 | // Number of digits. 21 | MAX_DISTANCE_UNITS: 5, 22 | 23 | // Distance that causes achievement animation. 24 | ACHIEVEMENT_DISTANCE: 100, 25 | 26 | // Used for conversion from pixel distance to a scaled unit. 27 | COEFFICIENT: 0.025, 28 | 29 | // Flash duration in milliseconds. 30 | FLASH_DURATION: 1000 / 4, 31 | 32 | // Flash iterations for achievement animation. 33 | FLASH_ITERATIONS: 3 34 | }; 35 | 36 | constructor(canvas, spritePos, canvasWidth) { 37 | this.canvas = canvas; 38 | this.canvasCtx = canvas.getContext('2d'); 39 | this.image = getImageSprite(); 40 | this.spritePos = spritePos; 41 | this.x = 0; 42 | this.y = 5; 43 | 44 | this.currentDistance = 0; 45 | this.maxScore = 0; 46 | this.highScore = 0; 47 | this.container = null; 48 | 49 | this.digits = []; 50 | this.acheivement = false; 51 | this.defaultString = ''; 52 | this.flashTimer = 0; 53 | this.flashIterations = 0; 54 | this.invertTrigger = false; 55 | 56 | this.config = DistanceMeter.config; 57 | this.maxScoreUnits = this.config.MAX_DISTANCE_UNITS; 58 | this.init(canvasWidth); 59 | } 60 | 61 | /** 62 | * Initialise the distance meter to '00000'. 63 | * @param {number} width Canvas width in px. 64 | */ 65 | init(width) { 66 | let maxDistanceStr = ''; 67 | 68 | this.calcXPos(width); 69 | this.maxScore = this.maxScoreUnits; 70 | for (let i = 0; i < this.maxScoreUnits; i += 1) { 71 | this.draw(i, 0); 72 | this.defaultString += '0'; 73 | maxDistanceStr += '9'; 74 | } 75 | 76 | this.maxScore = parseInt(maxDistanceStr, 0); 77 | } 78 | 79 | /** 80 | * Calculate the xPos in the canvas. 81 | * @param {number} canvasWidth 82 | */ 83 | calcXPos(canvasWidth) { 84 | this.x = 85 | canvasWidth - 86 | DistanceMeter.dimensions.DEST_WIDTH * (this.maxScoreUnits + 1); 87 | } 88 | 89 | /** 90 | * Draw a digit to canvas. 91 | * @param {number} digitPos Position of the digit. 92 | * @param {number} value Digit value 0-9. 93 | * @param {boolean} highScore Whether drawing the high score. 94 | */ 95 | draw(digitPos, value, highScore) { 96 | const sourceWidth = DistanceMeter.dimensions.WIDTH; 97 | const sourceHeight = DistanceMeter.dimensions.HEIGHT; 98 | let sourceX = DistanceMeter.dimensions.WIDTH * value; 99 | let sourceY = 0; 100 | 101 | const targetX = digitPos * DistanceMeter.dimensions.DEST_WIDTH; 102 | const targetY = this.y; 103 | const targetWidth = DistanceMeter.dimensions.WIDTH; 104 | const targetHeight = DistanceMeter.dimensions.HEIGHT; 105 | 106 | sourceX += this.spritePos.x; 107 | sourceY += this.spritePos.y; 108 | 109 | this.canvasCtx.save(); 110 | 111 | if (highScore) { 112 | // Left of the current score. 113 | const highScoreX = 114 | this.x - this.maxScoreUnits * 2 * DistanceMeter.dimensions.WIDTH; 115 | this.canvasCtx.translate(highScoreX, this.y); 116 | } else { 117 | this.canvasCtx.translate(this.x, this.y); 118 | } 119 | 120 | this.canvasCtx.drawImage( 121 | this.image, 122 | sourceX, 123 | sourceY, 124 | sourceWidth, 125 | sourceHeight, 126 | targetX, 127 | targetY, 128 | targetWidth, 129 | targetHeight 130 | ); 131 | 132 | this.canvasCtx.restore(); 133 | } 134 | 135 | /** 136 | * Covert pixel distance to a 'real' distance. 137 | * @param {number} distance Pixel distance ran. 138 | * @return {number} The 'real' distance ran. 139 | */ 140 | getActualDistance(distance) { 141 | return distance ? Math.round(distance * this.config.COEFFICIENT) : 0; 142 | } 143 | 144 | /** 145 | * Update the distance meter. 146 | * @param {number} distance 147 | * @param {number} deltaTime 148 | */ 149 | update(deltaTime, distance) { 150 | let paint = true; 151 | 152 | if (!this.acheivement) { 153 | distance = this.getActualDistance(distance); 154 | // Score has gone beyond the initial digit count. 155 | if ( 156 | distance > this.maxScore && 157 | this.maxScoreUnits === this.config.MAX_DISTANCE_UNITS 158 | ) { 159 | this.maxScoreUnits += 1; 160 | this.maxScore = parseInt(`${this.maxScore}9`, 1); 161 | } else { 162 | this.distance = 0; 163 | } 164 | 165 | if (distance > 0) { 166 | // Acheivement unlocked 167 | if (distance % this.config.ACHIEVEMENT_DISTANCE === 0) { 168 | this.acheivement = true; 169 | this.flashTimer = 0; 170 | } 171 | 172 | // Create a string representation of the distance with leading 0. 173 | const distanceStr = (this.defaultString + distance).substr( 174 | -this.maxScoreUnits 175 | ); 176 | this.digits = distanceStr.split(''); 177 | } else { 178 | this.digits = this.defaultString.split(''); 179 | } 180 | } else if (this.flashIterations <= this.config.FLASH_ITERATIONS) { 181 | this.flashTimer += deltaTime; 182 | 183 | if (this.flashTimer < this.config.FLASH_DURATION) { 184 | paint = false; 185 | } else if (this.flashTimer > this.config.FLASH_DURATION * 2) { 186 | this.flashTimer = 0; 187 | this.flashIterations += 1; 188 | } 189 | } else { 190 | this.acheivement = false; 191 | this.flashIterations = 0; 192 | this.flashTimer = 0; 193 | } 194 | 195 | // Draw the digits if not flashing. 196 | if (paint) { 197 | for (let i = this.digits.length - 1; i >= 0; i -= 1) { 198 | this.draw(i, parseInt(this.digits[i], 0)); 199 | } 200 | } 201 | 202 | this.drawHighScore(); 203 | } 204 | /** 205 | * Draw the high score. 206 | */ 207 | drawHighScore() { 208 | this.canvasCtx.save(); 209 | this.canvasCtx.globalAlpha = 0.8; 210 | for (let i = this.highScore.length - 1; i >= 0; i -= 1) { 211 | this.draw(i, parseInt(this.highScore[i], 10), true); 212 | } 213 | this.canvasCtx.restore(); 214 | } 215 | 216 | /** 217 | * Set the highscore as a array string. 218 | * Position of char in the sprite: H - 10, I - 11. 219 | * @param {number} distance Distance ran in pixels. 220 | */ 221 | setHighScore(distance) { 222 | distance = this.getActualDistance(distance); 223 | const highScoreStr = (this.defaultString + distance).substr( 224 | -this.maxScoreUnits 225 | ); 226 | 227 | this.highScore = ['10', '11', ''].concat(highScoreStr.split('')); 228 | } 229 | 230 | /** 231 | * Reset the distance meter back to '00000'. 232 | */ 233 | reset() { 234 | this.update(0); 235 | this.acheivement = false; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/game/Horizon.js: -------------------------------------------------------------------------------- 1 | import { getRandomNum } from './utils'; 2 | import { RUNNER_MAX_OBSTACLE_DUPLICATION } from './constants'; 3 | import Cloud from './Cloud'; 4 | import HorizonLine from './HorizonLine'; 5 | import Obstacle from './Obstacle'; 6 | 7 | /** 8 | * Horizon background class. 9 | * @param {HTMLCanvasElement} canvas 10 | * @param {Object} spritePos Sprite positioning. 11 | * @param {Object} dimensions Canvas dimensions. 12 | * @param {number} gapCoefficient 13 | * @constructor 14 | */ 15 | export default class Horizon { 16 | /** 17 | * Horizon config. 18 | * @enum {number} 19 | */ 20 | static config = { 21 | BG_CLOUD_SPEED: 0.2, 22 | BUMPY_THRESHOLD: 0.3, 23 | CLOUD_FREQUENCY: 0.5, 24 | HORIZON_HEIGHT: 16, 25 | MAX_CLOUDS: 6 26 | }; 27 | 28 | constructor(canvas, spritePos, dimensions, gapCoefficient) { 29 | this.canvas = canvas; 30 | this.canvasCtx = this.canvas.getContext('2d'); 31 | this.config = Horizon.config; 32 | this.dimensions = dimensions; 33 | this.gapCoefficient = gapCoefficient; 34 | this.obstacles = []; 35 | this.obstacleHistory = []; 36 | this.horizonOffsets = [0, 0]; 37 | this.cloudFrequency = this.config.CLOUD_FREQUENCY; 38 | this.spritePos = spritePos; 39 | 40 | // Cloud 41 | this.clouds = []; 42 | this.cloudSpeed = this.config.BG_CLOUD_SPEED; 43 | 44 | // Horizon 45 | this.horizonLine = null; 46 | this.init(); 47 | } 48 | 49 | /** 50 | * Initialise the horizon. Just add the line and a cloud. No obstacles. 51 | */ 52 | init() { 53 | this.addCloud(); 54 | this.horizonLine = new HorizonLine(this.canvas, this.spritePos.HORIZON); 55 | } 56 | 57 | /** 58 | * @param {number} deltaTime 59 | * @param {number} currentSpeed 60 | * @param {boolean} updateObstacles Used as an override to prevent 61 | * the obstacles from being updated / added. This happens in the 62 | * ease in section. 63 | */ 64 | update(deltaTime, currentSpeed, updateObstacles) { 65 | this.runningTime += deltaTime; 66 | this.horizonLine.update(deltaTime, currentSpeed); 67 | this.updateClouds(deltaTime, currentSpeed); 68 | 69 | if (updateObstacles) { 70 | this.updateObstacles(deltaTime, currentSpeed); 71 | } 72 | } 73 | 74 | /** 75 | * Update the cloud positions. 76 | * @param {number} deltaTime 77 | * @param {number} currentSpeed 78 | */ 79 | updateClouds(deltaTime, speed) { 80 | const cloudSpeed = this.cloudSpeed / 1000 * deltaTime * speed; 81 | const numClouds = this.clouds.length; 82 | 83 | if (numClouds) { 84 | for (let i = numClouds - 1; i >= 0; i -= 1) { 85 | this.clouds[i].update(cloudSpeed); 86 | } 87 | 88 | const lastCloud = this.clouds[numClouds - 1]; 89 | 90 | // Check for adding a new cloud. 91 | if ( 92 | numClouds < this.config.MAX_CLOUDS && 93 | this.dimensions.WIDTH - lastCloud.xPos > lastCloud.cloudGap && 94 | this.cloudFrequency > Math.random() 95 | ) { 96 | this.addCloud(); 97 | } 98 | 99 | // Remove expired clouds. 100 | this.clouds = this.clouds.filter(obj => !obj.remove); 101 | } else { 102 | this.addCloud(); 103 | } 104 | } 105 | 106 | /** 107 | * Update the obstacle positions. 108 | * @param {number} deltaTime 109 | * @param {number} currentSpeed 110 | */ 111 | updateObstacles(deltaTime, currentSpeed) { 112 | // Obstacles, move to Horizon layer. 113 | const updatedObstacles = this.obstacles.slice(0); 114 | 115 | for (let i = 0; i < this.obstacles.length; i += 1) { 116 | const obstacle = this.obstacles[i]; 117 | obstacle.update(deltaTime, currentSpeed); 118 | 119 | // Clean up existing obstacles. 120 | if (obstacle.remove) { 121 | updatedObstacles.shift(); 122 | } 123 | } 124 | this.obstacles = updatedObstacles; 125 | 126 | if (this.obstacles.length > 0) { 127 | const lastObstacle = this.obstacles[this.obstacles.length - 1]; 128 | 129 | if ( 130 | lastObstacle && 131 | !lastObstacle.followingObstacleCreated && 132 | lastObstacle.isVisible() && 133 | lastObstacle.xPos + lastObstacle.width + lastObstacle.gap < 134 | this.dimensions.WIDTH 135 | ) { 136 | this.addNewObstacle(currentSpeed); 137 | lastObstacle.followingObstacleCreated = true; 138 | } 139 | } else { 140 | // Create new obstacles. 141 | this.addNewObstacle(currentSpeed); 142 | } 143 | } 144 | 145 | removeFirstObstacle() { 146 | this.obstacles.shift(); 147 | } 148 | 149 | /** 150 | * Add a new obstacle. 151 | * @param {number} currentSpeed 152 | */ 153 | addNewObstacle(currentSpeed) { 154 | const obstacleTypeIndex = getRandomNum(0, Obstacle.types.length - 1); 155 | const obstacleType = Obstacle.types[obstacleTypeIndex]; 156 | 157 | // Check for multiples of the same type of obstacle. 158 | // Also check obstacle is available at current speed. 159 | if ( 160 | this.duplicateObstacleCheck(obstacleType.type) || 161 | currentSpeed < obstacleType.minSpeed 162 | ) { 163 | this.addNewObstacle(currentSpeed); 164 | } else { 165 | const obstacleSpritePos = this.spritePos[obstacleType.type]; 166 | 167 | this.obstacles.push( 168 | new Obstacle( 169 | this.canvasCtx, 170 | obstacleType, 171 | obstacleSpritePos, 172 | this.dimensions, 173 | this.gapCoefficient, 174 | currentSpeed, 175 | obstacleType.width 176 | ) 177 | ); 178 | 179 | this.obstacleHistory.unshift(obstacleType.type); 180 | 181 | if (this.obstacleHistory.length > 1) { 182 | this.obstacleHistory.splice(RUNNER_MAX_OBSTACLE_DUPLICATION); 183 | } 184 | } 185 | } 186 | 187 | /** 188 | * Returns whether the previous two obstacles are the same as the next one. 189 | * Maximum duplication is set in config value MAX_OBSTACLE_DUPLICATION. 190 | * @return {boolean} 191 | */ 192 | duplicateObstacleCheck(nextObstacleType) { 193 | let duplicateCount = 0; 194 | 195 | for (let i = 0; i < this.obstacleHistory.length; i += 1) { 196 | duplicateCount = 197 | this.obstacleHistory[i] === nextObstacleType ? duplicateCount + 1 : 0; 198 | } 199 | return duplicateCount >= RUNNER_MAX_OBSTACLE_DUPLICATION; 200 | } 201 | 202 | /** 203 | * Reset the horizon layer. 204 | * Remove existing obstacles and reposition the horizon line. 205 | */ 206 | reset() { 207 | this.obstacles = []; 208 | this.horizonLine.reset(); 209 | } 210 | 211 | /** 212 | * Update the canvas width and scaling. 213 | * @param {number} width Canvas width. 214 | * @param {number} height Canvas height. 215 | */ 216 | resize(width, height) { 217 | this.canvas.width = width; 218 | this.canvas.height = height; 219 | } 220 | 221 | /** 222 | * Add a new cloud to the horizon. 223 | */ 224 | addCloud() { 225 | this.clouds.push( 226 | new Cloud(this.canvas, this.spritePos.CLOUD, this.dimensions.WIDTH) 227 | ); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/game/HorizonLine.js: -------------------------------------------------------------------------------- 1 | import { getFPS } from './RuntimeConfig'; 2 | import { getImageSprite } from './ImageSprite'; 3 | 4 | /** 5 | * Horizon Line. 6 | * Consists of two connecting lines. Randomly assigns a flat / bumpy horizon. 7 | * @param {HTMLCanvasElement} canvas 8 | * @param {Object} spritePos Horizon position in sprite. 9 | * @constructor 10 | */ 11 | export default class HorizonLine { 12 | /** 13 | * Horizon line dimensions. 14 | * @enum {number} 15 | */ 16 | static dimensions = { 17 | WIDTH: 600, 18 | HEIGHT: 12, 19 | YPOS: 127 20 | }; 21 | 22 | constructor(canvas, spritePos) { 23 | this.spritePos = spritePos; 24 | this.canvas = canvas; 25 | this.canvasCtx = canvas.getContext('2d'); 26 | this.sourceDimensions = {}; 27 | this.dimensions = HorizonLine.dimensions; 28 | this.sourceXPos = [ 29 | this.spritePos.x, 30 | this.spritePos.x + this.dimensions.WIDTH 31 | ]; 32 | this.xPos = []; 33 | this.yPos = 0; 34 | this.bumpThreshold = 0.5; 35 | 36 | this.setSourceDimensions(); 37 | this.draw(); 38 | } 39 | 40 | /** 41 | * Set the source dimensions of the horizon line. 42 | */ 43 | setSourceDimensions() { 44 | /* eslint-disable-next-line */ 45 | for (const dimension in HorizonLine.dimensions) { 46 | this.sourceDimensions[dimension] = HorizonLine.dimensions[dimension]; 47 | this.dimensions[dimension] = HorizonLine.dimensions[dimension]; 48 | } 49 | 50 | this.xPos = [0, HorizonLine.dimensions.WIDTH]; 51 | this.yPos = HorizonLine.dimensions.YPOS; 52 | } 53 | 54 | /** 55 | * Return the crop x position of a type. 56 | */ 57 | getRandomType() { 58 | return Math.random() > this.bumpThreshold ? this.dimensions.WIDTH : 0; 59 | } 60 | 61 | /** 62 | * Draw the horizon line. 63 | */ 64 | draw() { 65 | this.canvasCtx.drawImage( 66 | getImageSprite(), 67 | this.sourceXPos[0], 68 | this.spritePos.y, 69 | this.sourceDimensions.WIDTH, 70 | this.sourceDimensions.HEIGHT, 71 | this.xPos[0], 72 | this.yPos, 73 | this.dimensions.WIDTH, 74 | this.dimensions.HEIGHT 75 | ); 76 | 77 | this.canvasCtx.drawImage( 78 | getImageSprite(), 79 | this.sourceXPos[1], 80 | this.spritePos.y, 81 | this.sourceDimensions.WIDTH, 82 | this.sourceDimensions.HEIGHT, 83 | this.xPos[1], 84 | this.yPos, 85 | this.dimensions.WIDTH, 86 | this.dimensions.HEIGHT 87 | ); 88 | } 89 | 90 | /** 91 | * Update the x position of an indivdual piece of the line. 92 | * @param {number} pos Line position. 93 | * @param {number} increment 94 | */ 95 | updateXPos(pos, increment) { 96 | const line1 = pos; 97 | const line2 = pos === 0 ? 1 : 0; 98 | 99 | this.xPos[line1] -= increment; 100 | this.xPos[line2] = this.xPos[line1] + this.dimensions.WIDTH; 101 | 102 | if (this.xPos[line1] <= -this.dimensions.WIDTH) { 103 | this.xPos[line1] += this.dimensions.WIDTH * 2; 104 | this.xPos[line2] = this.xPos[line1] - this.dimensions.WIDTH; 105 | this.sourceXPos[line1] = this.getRandomType() + this.spritePos.x; 106 | } 107 | } 108 | 109 | /** 110 | * Update the horizon line. 111 | * @param {number} deltaTime 112 | * @param {number} speed 113 | */ 114 | update(deltaTime, speed) { 115 | const increment = Math.floor(speed * (getFPS() / 1000) * deltaTime); 116 | 117 | if (this.xPos[0] <= 0) { 118 | this.updateXPos(0, increment); 119 | } else { 120 | this.updateXPos(1, increment); 121 | } 122 | this.draw(); 123 | } 124 | 125 | /** 126 | * Reset horizon to the starting position. 127 | */ 128 | reset() { 129 | this.xPos[0] = 0; 130 | this.xPos[1] = HorizonLine.dimensions.WIDTH; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/game/ImageSprite.js: -------------------------------------------------------------------------------- 1 | let _imageSprite = null; 2 | 3 | export function getImageSprite() { 4 | return _imageSprite; 5 | } 6 | 7 | export function loadImageSprite() { 8 | return new Promise((resolve) => { 9 | const imageSprite = document.createElement('img'); 10 | imageSprite.src = require('./images/offline-sprite.png'); 11 | imageSprite.addEventListener('load', () => { 12 | _imageSprite = imageSprite; 13 | resolve(); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/game/Obstacle.js: -------------------------------------------------------------------------------- 1 | import { getFPS } from './RuntimeConfig'; 2 | import { getImageSprite } from './ImageSprite'; 3 | import { getRandomNum } from './utils'; 4 | import CollisionBox from './CollisionBox'; 5 | 6 | /** 7 | * Obstacle. 8 | * @param {HTMLCanvasCtx} canvasCtx 9 | * @param {Obstacle.type} type 10 | * @param {Object} spritePos Obstacle position in sprite. 11 | * @param {Object} dimensions 12 | * @param {number} gapCoefficient Mutipler in determining the gap. 13 | * @param {number} speed 14 | * @param {number} offset 15 | */ 16 | export default class Obstacle { 17 | /** 18 | * Coefficient for calculating the maximum gap. 19 | * @const 20 | */ 21 | static MAX_GAP_COEFFICIENT = 1.5; 22 | 23 | /** 24 | * Maximum obstacle grouping count. 25 | * @const 26 | */ 27 | static MAX_OBSTACLE_LENGTH = 3; 28 | 29 | static types = [ 30 | { 31 | type: 'CACTUS_SMALL', 32 | width: 17, 33 | height: 35, 34 | yPos: 105, 35 | multipleSpeed: 4, 36 | minGap: 120, 37 | minSpeed: 0, 38 | collisionBoxes: [ 39 | new CollisionBox(0, 7, 5, 27), 40 | new CollisionBox(4, 0, 6, 34), 41 | new CollisionBox(10, 4, 7, 14) 42 | ] 43 | }, 44 | { 45 | type: 'CACTUS_LARGE', 46 | width: 25, 47 | height: 50, 48 | yPos: 90, 49 | multipleSpeed: 7, 50 | minGap: 120, 51 | minSpeed: 0, 52 | collisionBoxes: [ 53 | new CollisionBox(0, 12, 7, 38), 54 | new CollisionBox(8, 0, 7, 49), 55 | new CollisionBox(13, 10, 10, 38) 56 | ] 57 | }, 58 | { 59 | type: 'PTERODACTYL', 60 | width: 46, 61 | height: 40, 62 | yPos: [100, 75, 50], // Variable height. 63 | yPosMobile: [100, 50], // Variable height mobile. 64 | multipleSpeed: 999, 65 | minSpeed: 8.5, 66 | minGap: 150, 67 | collisionBoxes: [ 68 | new CollisionBox(15, 15, 16, 5), 69 | new CollisionBox(18, 21, 24, 6), 70 | new CollisionBox(2, 14, 4, 3), 71 | new CollisionBox(6, 10, 4, 7), 72 | new CollisionBox(10, 8, 6, 9) 73 | ], 74 | numFrames: 2, 75 | frameRate: 1000 / 6, 76 | speedOffset: 0.8 77 | } 78 | ]; 79 | 80 | constructor( 81 | canvasCtx, 82 | type, 83 | spriteImgPos, 84 | dimensions, 85 | gapCoefficient, 86 | speed, 87 | offset 88 | ) { 89 | this.canvasCtx = canvasCtx; 90 | this.spritePos = spriteImgPos; 91 | this.typeConfig = type; 92 | this.gapCoefficient = gapCoefficient; 93 | this.size = getRandomNum(1, Obstacle.MAX_OBSTACLE_LENGTH); 94 | this.dimensions = dimensions; 95 | this.remove = false; 96 | this.xPos = dimensions.WIDTH + (offset || 0); 97 | this.yPos = 0; 98 | this.width = 0; 99 | this.collisionBoxes = []; 100 | this.gap = 0; 101 | this.speedOffset = 0; 102 | 103 | // For animated obstacles. 104 | this.currentFrame = 0; 105 | this.timer = 0; 106 | 107 | this.init(speed); 108 | } 109 | 110 | init(speed) { 111 | this.cloneCollisionBoxes(); 112 | 113 | // Only allow sizing if we're at the right speed. 114 | if (this.size > 1 && this.typeConfig.multipleSpeed > speed) { 115 | this.size = 1; 116 | } 117 | 118 | this.width = this.typeConfig.width * this.size; 119 | 120 | // Check if obstacle can be positioned at various heights. 121 | if (Array.isArray(this.typeConfig.yPos)) { 122 | const yPosConfig = this.typeConfig.yPos; 123 | this.yPos = yPosConfig[getRandomNum(0, yPosConfig.length - 1)]; 124 | } else { 125 | this.yPos = this.typeConfig.yPos; 126 | } 127 | 128 | this.draw(); 129 | 130 | // Make collision box adjustments, 131 | // Central box is adjusted to the size as one box. 132 | // ____ ______ ________ 133 | // _| |-| _| |-| _| |-| 134 | // | |<->| | | |<--->| | | |<----->| | 135 | // | | 1 | | | | 2 | | | | 3 | | 136 | // |_|___|_| |_|_____|_| |_|_______|_| 137 | // 138 | if (this.size > 1) { 139 | this.collisionBoxes[1].width = 140 | this.width - 141 | this.collisionBoxes[0].width - 142 | this.collisionBoxes[2].width; 143 | this.collisionBoxes[2].x = this.width - this.collisionBoxes[2].width; 144 | } 145 | 146 | // For obstacles that go at a different speed from the horizon. 147 | if (this.typeConfig.speedOffset) { 148 | this.speedOffset = 149 | Math.random() > 0.5 150 | ? this.typeConfig.speedOffset 151 | : -this.typeConfig.speedOffset; 152 | } 153 | 154 | this.gap = this.getGap(this.gapCoefficient, speed); 155 | } 156 | 157 | /** 158 | * Draw and crop based on size. 159 | */ 160 | draw() { 161 | const sourceWidth = this.typeConfig.width; 162 | const sourceHeight = this.typeConfig.height; 163 | 164 | // X position in sprite. 165 | let sourceX = 166 | sourceWidth * this.size * (0.5 * (this.size - 1)) + this.spritePos.x; 167 | 168 | // Animation frames. 169 | if (this.currentFrame > 0) { 170 | sourceX += sourceWidth * this.currentFrame; 171 | } 172 | 173 | this.canvasCtx.drawImage( 174 | getImageSprite(), 175 | sourceX, 176 | this.spritePos.y, 177 | sourceWidth * this.size, 178 | sourceHeight, 179 | this.xPos, 180 | this.yPos, 181 | this.typeConfig.width * this.size, 182 | this.typeConfig.height 183 | ); 184 | } 185 | 186 | /** 187 | * Obstacle frame update. 188 | * @param {number} deltaTime 189 | * @param {number} speed 190 | */ 191 | update(deltaTime, speed) { 192 | if (!this.remove) { 193 | if (this.typeConfig.speedOffset) { 194 | speed += this.speedOffset; 195 | } 196 | this.xPos -= Math.floor(speed * getFPS() / 1000 * deltaTime); 197 | 198 | // Update frame 199 | if (this.typeConfig.numFrames) { 200 | this.timer += deltaTime; 201 | if (this.timer >= this.typeConfig.frameRate) { 202 | this.currentFrame = 203 | this.currentFrame === this.typeConfig.numFrames - 1 204 | ? 0 205 | : this.currentFrame + 1; 206 | this.timer = 0; 207 | } 208 | } 209 | this.draw(); 210 | 211 | if (!this.isVisible()) { 212 | this.remove = true; 213 | } 214 | } 215 | } 216 | 217 | /** 218 | * Calculate a random gap size. 219 | * - Minimum gap gets wider as speed increses 220 | * @param {number} gapCoefficient 221 | * @param {number} speed 222 | * @return {number} The gap size. 223 | */ 224 | getGap(gapCoefficient, speed) { 225 | const minGap = Math.round( 226 | this.width * speed + this.typeConfig.minGap * gapCoefficient 227 | ); 228 | const maxGap = Math.round(minGap * Obstacle.MAX_GAP_COEFFICIENT); 229 | return getRandomNum(minGap, maxGap); 230 | } 231 | 232 | /** 233 | * Check if obstacle is visible. 234 | * @return {boolean} Whether the obstacle is in the game area. 235 | */ 236 | isVisible() { 237 | return this.xPos + this.width > 0; 238 | } 239 | 240 | /** 241 | * Make a copy of the collision boxes, since these will change based on 242 | * obstacle type and size. 243 | */ 244 | cloneCollisionBoxes() { 245 | const { collisionBoxes } = this.typeConfig; 246 | 247 | for (let i = collisionBoxes.length - 1; i >= 0; i -= 1) { 248 | this.collisionBoxes[i] = new CollisionBox( 249 | collisionBoxes[i].x, 250 | collisionBoxes[i].y, 251 | collisionBoxes[i].width, 252 | collisionBoxes[i].height 253 | ); 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/game/Runner.js: -------------------------------------------------------------------------------- 1 | import { CANVAS_WIDTH, CANVAS_HEIGHT } from './constants'; 2 | import { getFPS } from './RuntimeConfig'; 3 | import { getImageSprite, setImageSprite, loadImageSprite } from './ImageSprite'; 4 | import { getTimeStamp } from './utils'; 5 | import DistanceMeter from './DistanceMeter'; 6 | import Horizon from './Horizon'; 7 | import Trex, { checkForCollision } from './Trex'; 8 | import TrexGroup from './TrexGroup'; 9 | 10 | /** 11 | * T-Rex runner. 12 | * @param {string} outerContainerId Outer containing element id. 13 | * @param {Object} options 14 | * @constructor 15 | * @export 16 | */ 17 | export default class Runner { 18 | static generation = 0; 19 | 20 | static config = { 21 | ACCELERATION: 0.001, 22 | BG_CLOUD_SPEED: 0.2, 23 | CLEAR_TIME: 0, 24 | CLOUD_FREQUENCY: 0.5, 25 | GAP_COEFFICIENT: 0.6, 26 | GRAVITY: 0.6, 27 | INITIAL_JUMP_VELOCITY: 12, 28 | MAX_CLOUDS: 6, 29 | MAX_OBSTACLE_LENGTH: 3, 30 | MAX_SPEED: 13, 31 | MIN_JUMP_HEIGHT: 35, 32 | SPEED: 6, 33 | SPEED_DROP_COEFFICIENT: 3, 34 | T_REX_COUNT: 1, 35 | // Events 36 | onReset: noop, 37 | onRunning: noop, 38 | onCrash: noop 39 | }; 40 | 41 | static classes = { 42 | CANVAS: 'game-canvas', 43 | CONTAINER: 'game-container', 44 | }; 45 | 46 | static spriteDefinition = { 47 | CACTUS_LARGE: { x: 332, y: 2 }, 48 | CACTUS_SMALL: { x: 228, y: 2 }, 49 | CLOUD: { x: 86, y: 2 }, 50 | HORIZON: { x: 2, y: 54 }, 51 | PTERODACTYL: { x: 134, y: 2 }, 52 | RESTART: { x: 2, y: 2 }, 53 | TEXT_SPRITE: { x: 655, y: 2 }, 54 | TREX: { x: 848, y: 2 } 55 | }; 56 | 57 | /** 58 | * Key code mapping. 59 | * @enum {Object} 60 | */ 61 | static keycodes = { 62 | JUMP: { 38: 1, 32: 1 }, // Up, spacebar 63 | DUCK: { 40: 1 } // Down 64 | }; 65 | 66 | /** 67 | * Runner event names. 68 | * @enum {string} 69 | */ 70 | static events = { 71 | ANIM_END: 'webkitAnimationEnd', 72 | CLICK: 'click', 73 | KEYDOWN: 'keydown', 74 | KEYUP: 'keyup', 75 | RESIZE: 'resize', 76 | VISIBILITY: 'visibilitychange', 77 | BLUR: 'blur', 78 | FOCUS: 'focus', 79 | LOAD: 'load' 80 | }; 81 | 82 | constructor(outerContainerId, options) { 83 | // Singleton 84 | if (Runner.instance_) { 85 | return Runner.instance_; 86 | } 87 | Runner.instance_ = this; 88 | 89 | this.isFirstTime = false; 90 | this.outerContainerEl = document.querySelector(outerContainerId); 91 | this.generationEl = document.querySelector('.generation'); 92 | this.containerEl = null; 93 | 94 | this.config = Object.assign({}, Runner.config, options); 95 | 96 | this.dimensions = { 97 | WIDTH: CANVAS_WIDTH, 98 | HEIGHT: CANVAS_HEIGHT 99 | }; 100 | 101 | this.canvas = null; 102 | this.canvasCtx = null; 103 | 104 | this.tRex = null; 105 | 106 | this.distanceMeter = null; 107 | this.distanceRan = 0; 108 | 109 | this.highestScore = 0; 110 | 111 | this.time = 0; 112 | this.runningTime = 0; 113 | this.msPerFrame = 1000 / getFPS(); 114 | this.currentSpeed = this.config.SPEED; 115 | 116 | this.obstacles = []; 117 | 118 | this.activated = false; // Whether the easter egg has been activated. 119 | this.playing = false; // Whether the game is currently in play state. 120 | this.crashed = false; 121 | this.resizeTimerId_ = null; 122 | 123 | this.playCount = 0; 124 | 125 | // Images. 126 | this.images = {}; 127 | this.imagesLoaded = 0; 128 | } 129 | 130 | async init() { 131 | await loadImageSprite(); 132 | this.spriteDef = Runner.spriteDefinition; 133 | 134 | this.adjustDimensions(); 135 | this.setSpeed(); 136 | 137 | this.containerEl = document.createElement('div'); 138 | this.containerEl.className = Runner.classes.CONTAINER; 139 | this.containerEl.style.width = `${this.dimensions.WIDTH}px`; 140 | 141 | // Player canvas container. 142 | this.canvas = createCanvas( 143 | this.containerEl, 144 | this.dimensions.WIDTH, 145 | this.dimensions.HEIGHT, 146 | Runner.classes.PLAYER 147 | ); 148 | 149 | this.canvasCtx = this.canvas.getContext('2d'); 150 | this.canvasCtx.fillStyle = '#f7f7f7'; 151 | this.canvasCtx.fill(); 152 | Runner.updateCanvasScaling(this.canvas); 153 | 154 | // Horizon contains clouds, obstacles and the ground. 155 | this.horizon = new Horizon( 156 | this.canvas, 157 | this.spriteDef, 158 | this.dimensions, 159 | this.config.GAP_COEFFICIENT 160 | ); 161 | 162 | // Distance meter 163 | this.distanceMeter = new DistanceMeter( 164 | this.canvas, 165 | this.spriteDef.TEXT_SPRITE, 166 | this.dimensions.WIDTH 167 | ); 168 | 169 | // Draw t-rex 170 | this.tRexGroup = new TrexGroup(this.config.T_REX_COUNT, this.canvas, this.spriteDef.TREX); 171 | this.tRexGroup.onRunning = this.config.onRunning; 172 | this.tRexGroup.onCrash = this.config.onCrash; 173 | this.tRex = this.tRexGroup.tRexes[0]; 174 | 175 | this.outerContainerEl.appendChild(this.containerEl); 176 | 177 | this.startListening(); 178 | this.update(); 179 | 180 | window.addEventListener( 181 | Runner.events.RESIZE, 182 | this.debounceResize.bind(this) 183 | ); 184 | 185 | this.restart(); 186 | } 187 | 188 | /** 189 | * Debounce the resize event. 190 | */ 191 | debounceResize() { 192 | if (!this.resizeTimerId_) { 193 | this.resizeTimerId_ = setInterval(this.adjustDimensions.bind(this), 250); 194 | } 195 | } 196 | 197 | /** 198 | * Adjust game space dimensions on resize. 199 | */ 200 | adjustDimensions() { 201 | clearInterval(this.resizeTimerId_); 202 | this.resizeTimerId_ = null; 203 | 204 | const boxStyles = window.getComputedStyle(this.outerContainerEl); 205 | const padding = Number( 206 | boxStyles.paddingLeft.substr(0, boxStyles.paddingLeft.length - 2) 207 | ); 208 | 209 | this.dimensions.WIDTH = this.outerContainerEl.offsetWidth - padding * 2; 210 | 211 | // Redraw the elements back onto the canvas. 212 | if (this.canvas) { 213 | this.canvas.width = this.dimensions.WIDTH; 214 | this.canvas.height = this.dimensions.HEIGHT; 215 | 216 | Runner.updateCanvasScaling(this.canvas); 217 | 218 | this.distanceMeter.calcXPos(this.dimensions.WIDTH); 219 | this.clearCanvas(); 220 | this.horizon.update(0, 0, true); 221 | this.tRexGroup.update(0); 222 | 223 | // Outer container and distance meter. 224 | if (this.playing || this.crashed) { 225 | this.containerEl.style.width = `${this.dimensions.WIDTH}px`; 226 | this.containerEl.style.height = `${this.dimensions.HEIGHT}px`; 227 | this.distanceMeter.update(0, Math.ceil(this.distanceRan)); 228 | this.stop(); 229 | } else { 230 | this.tRexGroup.draw(0, 0); 231 | } 232 | } 233 | } 234 | 235 | /** 236 | * Sets the game speed. Adjust the speed accordingly if on a smaller screen. 237 | * @param {number} speed 238 | */ 239 | setSpeed(speed) { 240 | this.currentSpeed = speed || this.currentSpeed; 241 | } 242 | 243 | /** 244 | * Update the game status to started. 245 | */ 246 | startGame() { 247 | this.runningTime = 0; 248 | this.containerEl.style.webkitAnimation = ''; 249 | this.playCount += 1; 250 | } 251 | 252 | clearCanvas() { 253 | this.canvasCtx.clearRect( 254 | 0, 255 | 0, 256 | this.dimensions.WIDTH, 257 | this.dimensions.HEIGHT 258 | ); 259 | } 260 | 261 | /** 262 | * Update the game frame and schedules the next one. 263 | */ 264 | update() { 265 | this.updatePending = false; 266 | 267 | const now = getTimeStamp(); 268 | let deltaTime = now - (this.time || now); 269 | this.time = now; 270 | 271 | if (this.playing) { 272 | this.clearCanvas(); 273 | 274 | this.tRexGroup.updateJump(deltaTime); 275 | 276 | this.runningTime += deltaTime; 277 | const hasObstacles = this.runningTime > this.config.CLEAR_TIME; 278 | 279 | // First time 280 | if (this.isFirstTime) { 281 | if (!this.activated && !this.crashed) { 282 | this.playing = true; 283 | this.activated = true; 284 | this.startGame(); 285 | } 286 | } 287 | 288 | deltaTime = !this.activated ? 0 : deltaTime; 289 | this.horizon.update(deltaTime, this.currentSpeed, hasObstacles); 290 | 291 | let gameOver = false; 292 | // Check for collisions. 293 | if (hasObstacles) { 294 | gameOver = this.tRexGroup.checkForCollision(this.horizon.obstacles[0]); 295 | } 296 | 297 | if (!gameOver) { 298 | this.distanceRan += this.currentSpeed * deltaTime / this.msPerFrame; 299 | 300 | if (this.currentSpeed < this.config.MAX_SPEED) { 301 | this.currentSpeed += this.config.ACCELERATION; 302 | } 303 | } else { 304 | this.gameOver(); 305 | } 306 | 307 | this.distanceMeter.update( 308 | deltaTime, 309 | Math.ceil(this.distanceRan) 310 | ); 311 | } 312 | 313 | if ( 314 | this.playing || 315 | (!this.activated) 316 | ) { 317 | this.tRexGroup.update(deltaTime); 318 | this.scheduleNextUpdate(); 319 | } 320 | 321 | const lives = this.tRexGroup.lives(); 322 | if (lives > 0) { 323 | this.generationEl.innerText = `GENERATION #${Runner.generation} | LIVE x ${this.tRexGroup.lives()}`; 324 | } else { 325 | this.generationEl.innerHTML = `
GENERATION #${Runner.generation} | GAME OVER
`; 326 | } 327 | } 328 | 329 | /** 330 | * Bind relevant key 331 | */ 332 | startListening() { 333 | document.addEventListener(Runner.events.KEYDOWN, (e) => { 334 | this.onKeyDown(e); 335 | }); 336 | document.addEventListener(Runner.events.KEYUP, (e) => { 337 | this.onKeyUp(e); 338 | }); 339 | } 340 | 341 | /** 342 | * Process keydown. 343 | * @param {Event} e 344 | */ 345 | onKeyDown(e) { 346 | if (!this.crashed && this.playing) { 347 | if (Runner.keycodes.JUMP[e.keyCode]) { 348 | e.preventDefault(); 349 | this.tRex.startJump(this.currentSpeed); 350 | } else if (Runner.keycodes.DUCK[e.keyCode]) { 351 | e.preventDefault(); 352 | if (this.tRex.jumping) { 353 | // Speed drop, activated only when jump key is not pressed. 354 | this.tRex.setSpeedDrop(); 355 | } else if (!this.tRex.jumping && !this.tRex.ducking) { 356 | // Duck. 357 | this.tRex.setDuck(true); 358 | } 359 | } 360 | } else if (this.crashed) { 361 | this.restart(); 362 | } 363 | } 364 | 365 | /** 366 | * Process key up. 367 | * @param {Event} e 368 | */ 369 | onKeyUp(e) { 370 | const keyCode = String(e.keyCode); 371 | const isJumpKey = Runner.keycodes.JUMP[keyCode]; 372 | 373 | if (this.isRunning() && isJumpKey) { 374 | this.tRex.endJump(); 375 | } else if (Runner.keycodes.DUCK[keyCode]) { 376 | this.tRex.speedDrop = false; 377 | this.tRex.setDuck(false); 378 | } else if (this.crashed) { 379 | if (Runner.keycodes.JUMP[keyCode]) { 380 | this.restart(); 381 | } 382 | } 383 | } 384 | 385 | /** 386 | * RequestAnimationFrame wrapper. 387 | */ 388 | scheduleNextUpdate() { 389 | if (!this.updatePending) { 390 | this.updatePending = true; 391 | this.raqId = requestAnimationFrame(this.update.bind(this)); 392 | } 393 | } 394 | 395 | /** 396 | * Whether the game is running. 397 | * @return {boolean} 398 | */ 399 | isRunning() { 400 | return !!this.raqId; 401 | } 402 | 403 | /** 404 | * Game over state. 405 | */ 406 | gameOver() { 407 | this.stop(); 408 | this.crashed = true; 409 | this.distanceMeter.acheivement = false; 410 | 411 | this.tRexGroup.update(100, Trex.status.CRASHED); 412 | 413 | // Update the high score. 414 | if (this.distanceRan > this.highestScore) { 415 | this.highestScore = Math.ceil(this.distanceRan); 416 | this.distanceMeter.setHighScore(this.highestScore); 417 | } 418 | 419 | // Reset the time clock. 420 | this.time = getTimeStamp(); 421 | 422 | setTimeout(() => { 423 | this.restart(); 424 | }, 500); 425 | } 426 | 427 | stop() { 428 | this.playing = false; 429 | cancelAnimationFrame(this.raqId); 430 | this.raqId = 0; 431 | } 432 | 433 | play() { 434 | if (!this.crashed) { 435 | this.playing = true; 436 | this.tRexGroup.update(0, Trex.status.RUNNING); 437 | this.time = getTimeStamp(); 438 | this.update(); 439 | } 440 | } 441 | 442 | restart() { 443 | if (!this.raqId) { 444 | this.playCount += 1; 445 | this.runningTime = 0; 446 | this.playing = true; 447 | this.crashed = false; 448 | this.distanceRan = 0; 449 | this.setSpeed(this.config.SPEED); 450 | this.time = getTimeStamp(); 451 | this.clearCanvas(); 452 | this.distanceMeter.reset(this.highestScore); 453 | this.horizon.reset(); 454 | this.tRexGroup.reset(); 455 | this.config.onReset({ tRexes: this.tRexGroup.tRexes }); 456 | this.update(); 457 | } else { 458 | this.isFirstTime = true; 459 | this.tRexGroup.reset(); 460 | this.config.onReset({ tRexes: this.tRexGroup.tRexes }); 461 | if (!this.playing) { 462 | this.playing = true; 463 | this.update(); 464 | } 465 | } 466 | Runner.generation += 1; 467 | } 468 | 469 | /** 470 | * Updates the canvas size taking into 471 | * account the backing store pixel ratio and 472 | * the device pixel ratio. 473 | * 474 | * See article by Paul Lewis: 475 | * http://www.html5rocks.com/en/tutorials/canvas/hidpi/ 476 | * 477 | * @param {HTMLCanvasElement} canvas 478 | * @param {number} width 479 | * @param {number} height 480 | * @return {boolean} Whether the canvas was scaled. 481 | */ 482 | static updateCanvasScaling(canvas, width, height) { 483 | const context = canvas.getContext('2d'); 484 | 485 | // Query the various pixel ratios 486 | const devicePixelRatio = Math.floor(window.devicePixelRatio) || 1; 487 | const backingStoreRatio = 488 | Math.floor(context.webkitBackingStorePixelRatio) || 1; 489 | const ratio = devicePixelRatio / backingStoreRatio; 490 | 491 | // Upscale the canvas if the two ratios don't match 492 | if (devicePixelRatio !== backingStoreRatio) { 493 | const oldWidth = width || canvas.width; 494 | const oldHeight = height || canvas.height; 495 | 496 | canvas.width = oldWidth * ratio; 497 | canvas.height = oldHeight * ratio; 498 | 499 | canvas.style.width = `${oldWidth}px`; 500 | canvas.style.height = `${oldHeight}px`; 501 | 502 | // Scale the context to counter the fact that we've manually scaled 503 | // our canvas element. 504 | context.scale(ratio, ratio); 505 | return true; 506 | } else if (devicePixelRatio === 1) { 507 | // Reset the canvas width / height. Fixes scaling bug when the page is 508 | // zoomed and the devicePixelRatio changes accordingly. 509 | canvas.style.width = `${canvas.width}px`; 510 | canvas.style.height = `${canvas.height}px`; 511 | } 512 | return false; 513 | } 514 | } 515 | 516 | /** 517 | * Create canvas element. 518 | * @param {HTMLElement} container Element to append canvas to. 519 | * @param {number} width 520 | * @param {number} height 521 | * @param {string} className 522 | * @return {HTMLCanvasElement} 523 | */ 524 | function createCanvas(container, width, height, className) { 525 | const canvas = document.createElement('canvas'); 526 | canvas.className = className 527 | ? `${Runner.classes.CANVAS} ${className}` 528 | : Runner.classes.CANVAS; 529 | canvas.width = width; 530 | canvas.height = height; 531 | container.appendChild(canvas); 532 | 533 | return canvas; 534 | } 535 | 536 | function noop() {} 537 | -------------------------------------------------------------------------------- /src/game/RuntimeConfig.js: -------------------------------------------------------------------------------- 1 | let FPS = 60; 2 | 3 | export function getFPS() { 4 | return FPS; 5 | } 6 | 7 | export function setFPS(value) { 8 | FPS = value; 9 | } 10 | -------------------------------------------------------------------------------- /src/game/Trex.js: -------------------------------------------------------------------------------- 1 | import { 2 | CANVAS_WIDTH, 3 | CANVAS_HEIGHT, 4 | RUNNER_BOTTOM_PAD 5 | } from './constants'; 6 | import { getFPS } from './RuntimeConfig'; 7 | import { getImageSprite } from './ImageSprite'; 8 | import { getTimeStamp } from './utils'; 9 | import CollisionBox from './CollisionBox'; 10 | import Runner from './Runner'; 11 | 12 | /** 13 | * T-rex game character. 14 | * @param {HTMLCanvas} canvas 15 | * @param {Object} spritePos Positioning within image sprite. 16 | * @constructor 17 | */ 18 | export default class Trex { 19 | static config = { 20 | DROP_VELOCITY: -5, 21 | GRAVITY: 0.6, 22 | HEIGHT: 47, 23 | HEIGHT_DUCK: 25, 24 | INIITAL_JUMP_VELOCITY: -10, 25 | MAX_JUMP_HEIGHT: 30, 26 | MIN_JUMP_HEIGHT: 30, 27 | SPEED_DROP_COEFFICIENT: 3, 28 | SPRITE_WIDTH: 262, 29 | START_X_POS: 50, 30 | WIDTH: 44, 31 | WIDTH_DUCK: 59 32 | }; 33 | 34 | /** 35 | * Used in collision detection. 36 | * @type {Array} 37 | */ 38 | static collisionBoxes = { 39 | DUCKING: [new CollisionBox(1, 18, 55, 25)], 40 | RUNNING: [ 41 | new CollisionBox(22, 0, 17, 16), 42 | new CollisionBox(1, 18, 30, 9), 43 | new CollisionBox(10, 35, 14, 8), 44 | new CollisionBox(1, 24, 29, 5), 45 | new CollisionBox(5, 30, 21, 4), 46 | new CollisionBox(9, 34, 15, 4) 47 | ] 48 | }; 49 | 50 | /** 51 | * Animation states. 52 | * @enum {string} 53 | */ 54 | static status = { 55 | CRASHED: 'CRASHED', 56 | DUCKING: 'DUCKING', 57 | JUMPING: 'JUMPING', 58 | RUNNING: 'RUNNING', 59 | WAITING: 'WAITING' 60 | }; 61 | 62 | /** 63 | * Blinking coefficient. 64 | * @const 65 | */ 66 | static BLINK_TIMING = 7000; 67 | 68 | /** 69 | * Animation config for different states. 70 | * @enum {Object} 71 | */ 72 | static animFrames = { 73 | WAITING: { 74 | frames: [44, 0], 75 | msPerFrame: 1000 / 3 76 | }, 77 | RUNNING: { 78 | frames: [88, 132], 79 | msPerFrame: 1000 / 12 80 | }, 81 | CRASHED: { 82 | frames: [220], 83 | msPerFrame: 1000 / 60 84 | }, 85 | JUMPING: { 86 | frames: [0], 87 | msPerFrame: 1000 / 60 88 | }, 89 | DUCKING: { 90 | frames: [262, 321], 91 | msPerFrame: 1000 / 8 92 | } 93 | }; 94 | 95 | constructor(canvas, spritePos) { 96 | this.canvas = canvas; 97 | this.canvasCtx = canvas.getContext('2d'); 98 | this.spritePos = spritePos; 99 | this.xPos = 0; 100 | this.yPos = 0; 101 | // Position when on the ground. 102 | this.groundYPos = 0; 103 | this.currentFrame = 0; 104 | this.currentAnimFrames = []; 105 | this.blinkDelay = 0; 106 | this.blinkCount = 0; 107 | this.animStartTime = 0; 108 | this.timer = 0; 109 | this.msPerFrame = 1000 / getFPS(); 110 | this.config = Trex.config; 111 | // Current status. 112 | this.status = Trex.status.WAITING; 113 | 114 | this.jumping = false; 115 | this.ducking = false; 116 | this.jumpVelocity = 0; 117 | this.reachedMinHeight = false; 118 | this.speedDrop = false; 119 | this.jumpCount = 0; 120 | this.jumpspotX = 0; 121 | 122 | this.init(); 123 | } 124 | 125 | /** 126 | * T-rex player initaliser. 127 | * Sets the t-rex to blink at random intervals. 128 | */ 129 | init() { 130 | this.groundYPos = CANVAS_HEIGHT - this.config.HEIGHT - RUNNER_BOTTOM_PAD; 131 | this.yPos = this.groundYPos; 132 | this.minJumpHeight = this.groundYPos - this.config.MIN_JUMP_HEIGHT; 133 | 134 | this.draw(0, 0); 135 | this.update(0, Trex.status.WAITING); 136 | } 137 | 138 | /** 139 | * Setter for the jump velocity. 140 | * The approriate drop velocity is also set. 141 | */ 142 | setJumpVelocity(setting) { 143 | this.config.INIITAL_JUMP_VELOCITY = -setting; 144 | this.config.DROP_VELOCITY = -setting / 2; 145 | } 146 | 147 | /** 148 | * Set the animation status. 149 | * @param {!number} deltaTime 150 | * @param {Trex.status} status Optional status to switch to. 151 | */ 152 | update(deltaTime, status) { 153 | this.timer += deltaTime; 154 | 155 | // Update the status. 156 | if (status) { 157 | this.status = status; 158 | this.currentFrame = 0; 159 | this.msPerFrame = Trex.animFrames[status].msPerFrame; 160 | this.currentAnimFrames = Trex.animFrames[status].frames; 161 | 162 | if (status === Trex.status.WAITING) { 163 | this.animStartTime = getTimeStamp(); 164 | this.setBlinkDelay(); 165 | } 166 | } 167 | 168 | if (this.status === Trex.status.WAITING) { 169 | this.blink(getTimeStamp()); 170 | } else { 171 | this.draw(this.currentAnimFrames[this.currentFrame], 0); 172 | } 173 | 174 | // Update the frame position. 175 | if (this.timer >= this.msPerFrame) { 176 | this.currentFrame = 177 | this.currentFrame === this.currentAnimFrames.length - 1 178 | ? 0 179 | : this.currentFrame + 1; 180 | this.timer = 0; 181 | } 182 | 183 | // Speed drop becomes duck if the down key is still being pressed. 184 | if (this.speedDrop && this.yPos === this.groundYPos) { 185 | this.speedDrop = false; 186 | this.setDuck(true); 187 | } 188 | } 189 | 190 | /** 191 | * Draw the t-rex to a particular position. 192 | * @param {number} x 193 | * @param {number} y 194 | */ 195 | draw(x, y) { 196 | let sourceX = x; 197 | let sourceY = y; 198 | const sourceWidth = 199 | this.ducking && this.status !== Trex.status.CRASHED 200 | ? this.config.WIDTH_DUCK 201 | : this.config.WIDTH; 202 | const sourceHeight = this.config.HEIGHT; 203 | 204 | // Adjustments for sprite sheet position. 205 | sourceX += this.spritePos.x; 206 | sourceY += this.spritePos.y; 207 | 208 | // Ducking. 209 | if (this.ducking && this.status !== Trex.status.CRASHED) { 210 | this.canvasCtx.drawImage( 211 | getImageSprite(), 212 | sourceX, 213 | sourceY, 214 | sourceWidth, 215 | sourceHeight, 216 | this.xPos, 217 | this.yPos, 218 | this.config.WIDTH_DUCK, 219 | this.config.HEIGHT 220 | ); 221 | } else { 222 | // Crashed whilst ducking. Trex is standing up so needs adjustment. 223 | if (this.ducking && this.status === Trex.status.CRASHED) { 224 | this.xPos += 1; 225 | } 226 | // Standing / running 227 | this.canvasCtx.drawImage( 228 | getImageSprite(), 229 | sourceX, 230 | sourceY, 231 | sourceWidth, 232 | sourceHeight, 233 | this.xPos, 234 | this.yPos, 235 | this.config.WIDTH, 236 | this.config.HEIGHT 237 | ); 238 | } 239 | } 240 | 241 | /** 242 | * Sets a random time for the blink to happen. 243 | */ 244 | setBlinkDelay() { 245 | this.blinkDelay = Math.ceil(Math.random() * Trex.BLINK_TIMING); 246 | } 247 | 248 | /** 249 | * Make t-rex blink at random intervals. 250 | * @param {number} time Current time in milliseconds. 251 | */ 252 | blink(time) { 253 | const deltaTime = time - this.animStartTime; 254 | 255 | if (deltaTime >= this.blinkDelay) { 256 | this.draw(this.currentAnimFrames[this.currentFrame], 0); 257 | 258 | if (this.currentFrame === 1) { 259 | // Set new random delay to blink. 260 | this.setBlinkDelay(); 261 | this.animStartTime = time; 262 | this.blinkCount += 1; 263 | } 264 | } 265 | } 266 | 267 | /** 268 | * Initialise a jump. 269 | * @param {number} speed 270 | */ 271 | startJump(speed) { 272 | if (speed === undefined) { 273 | speed = Runner.instance_.currentSpeed; 274 | } 275 | if (!this.jumping) { 276 | this.update(0, Trex.status.JUMPING); 277 | // Tweak the jump velocity based on the speed. 278 | this.jumpVelocity = this.config.INIITAL_JUMP_VELOCITY - speed / 10; 279 | this.jumping = true; 280 | this.reachedMinHeight = false; 281 | this.speedDrop = false; 282 | } 283 | } 284 | 285 | /** 286 | * Jump is complete, falling down. 287 | */ 288 | endJump() { 289 | if ( 290 | this.reachedMinHeight && 291 | this.jumpVelocity < this.config.DROP_VELOCITY 292 | ) { 293 | this.jumpVelocity = this.config.DROP_VELOCITY; 294 | } 295 | } 296 | 297 | /** 298 | * Update frame for a jump. 299 | * @param {number} deltaTime 300 | * @param {number} speed 301 | */ 302 | updateJump(deltaTime, speed) { 303 | const { msPerFrame } = Trex.animFrames[this.status]; 304 | const framesElapsed = deltaTime / msPerFrame; 305 | 306 | // Speed drop makes Trex fall faster. 307 | if (this.speedDrop) { 308 | this.yPos += Math.round( 309 | this.jumpVelocity * this.config.SPEED_DROP_COEFFICIENT * framesElapsed 310 | ); 311 | } else { 312 | this.yPos += Math.round(this.jumpVelocity * framesElapsed); 313 | } 314 | 315 | this.jumpVelocity += this.config.GRAVITY * framesElapsed; 316 | 317 | // Minimum height has been reached. 318 | if (this.yPos < this.minJumpHeight || this.speedDrop) { 319 | this.reachedMinHeight = true; 320 | } 321 | 322 | // Reached max height 323 | if (this.yPos < this.config.MAX_JUMP_HEIGHT || this.speedDrop) { 324 | this.endJump(); 325 | } 326 | 327 | // Back down at ground level. Jump completed. 328 | if (this.yPos > this.groundYPos) { 329 | this.reset(); 330 | this.jumpCount += 1; 331 | } 332 | 333 | this.update(deltaTime); 334 | } 335 | 336 | /** 337 | * Set the speed drop. Immediately cancels the current jump. 338 | */ 339 | setSpeedDrop() { 340 | this.speedDrop = true; 341 | this.jumpVelocity = 1; 342 | } 343 | 344 | /** 345 | * @param {boolean} isDucking. 346 | */ 347 | setDuck(isDucking) { 348 | if (isDucking && this.status !== Trex.status.DUCKING) { 349 | this.update(0, Trex.status.DUCKING); 350 | this.ducking = true; 351 | } else if (this.status === Trex.status.DUCKING) { 352 | this.update(0, Trex.status.RUNNING); 353 | this.ducking = false; 354 | } 355 | } 356 | 357 | /** 358 | * Reset the t-rex to running at start of game. 359 | */ 360 | reset() { 361 | this.yPos = this.groundYPos; 362 | this.jumpVelocity = 0; 363 | this.jumping = false; 364 | this.ducking = false; 365 | this.update(0, Trex.status.RUNNING); 366 | this.midair = false; 367 | this.speedDrop = false; 368 | this.jumpCount = 0; 369 | this.crashed = false; 370 | } 371 | } 372 | 373 | /** 374 | * Check for a collision. 375 | * @param {!Obstacle} obstacle 376 | * @param {!Trex} tRex T-rex object. 377 | * @param {HTMLCanvasContext} canvasContext Optional canvas context for drawing 378 | * collision boxes. 379 | * @return {Array} 380 | */ 381 | export function checkForCollision(obstacle, tRex) { 382 | const obstacleBoxXPos = CANVAS_WIDTH + obstacle.xPos; 383 | 384 | // Adjustments are made to the bounding box as there is a 1 pixel white 385 | // border around the t-rex and obstacles. 386 | const tRexBox = new CollisionBox( 387 | tRex.xPos + 1, 388 | tRex.yPos + 1, 389 | tRex.config.WIDTH - 2, 390 | tRex.config.HEIGHT - 2 391 | ); 392 | 393 | const obstacleBox = new CollisionBox( 394 | obstacle.xPos + 1, 395 | obstacle.yPos + 1, 396 | obstacle.typeConfig.width * obstacle.size - 2, 397 | obstacle.typeConfig.height - 2 398 | ); 399 | 400 | // Simple outer bounds check. 401 | if (boxCompare(tRexBox, obstacleBox)) { 402 | const { collisionBoxes } = obstacle; 403 | const tRexCollisionBoxes = tRex.ducking 404 | ? Trex.collisionBoxes.DUCKING 405 | : Trex.collisionBoxes.RUNNING; 406 | 407 | // Detailed axis aligned box check. 408 | for (let t = 0; t < tRexCollisionBoxes.length; t += 1) { 409 | for (let i = 0; i < collisionBoxes.length; i += 1) { 410 | // Adjust the box to actual positions. 411 | const adjTrexBox = createAdjustedCollisionBox( 412 | tRexCollisionBoxes[t], 413 | tRexBox 414 | ); 415 | const adjObstacleBox = createAdjustedCollisionBox( 416 | collisionBoxes[i], 417 | obstacleBox 418 | ); 419 | const crashed = boxCompare(adjTrexBox, adjObstacleBox); 420 | 421 | if (crashed) { 422 | return [adjTrexBox, adjObstacleBox]; 423 | } 424 | } 425 | } 426 | } 427 | return false; 428 | } 429 | 430 | /** 431 | * Adjust the collision box. 432 | * @param {!CollisionBox} box The original box. 433 | * @param {!CollisionBox} adjustment Adjustment box. 434 | * @return {CollisionBox} The adjusted collision box object. 435 | */ 436 | function createAdjustedCollisionBox(box, adjustment) { 437 | return new CollisionBox( 438 | box.x + adjustment.x, 439 | box.y + adjustment.y, 440 | box.width, 441 | box.height 442 | ); 443 | } 444 | 445 | /** 446 | * Compare two collision boxes for a collision. 447 | * @param {CollisionBox} tRexBox 448 | * @param {CollisionBox} obstacleBox 449 | * @return {boolean} Whether the boxes intersected. 450 | */ 451 | function boxCompare(tRexBox, obstacleBox) { 452 | let crashed = false; 453 | const tRexBoxX = tRexBox.x; 454 | const tRexBoxY = tRexBox.y; 455 | 456 | const obstacleBoxX = obstacleBox.x; 457 | const obstacleBoxY = obstacleBox.y; 458 | 459 | // Axis-Aligned Bounding Box method. 460 | if ( 461 | tRexBox.x < obstacleBoxX + obstacleBox.width && 462 | tRexBox.x + tRexBox.width > obstacleBoxX && 463 | tRexBox.y < obstacleBox.y + obstacleBox.height && 464 | tRexBox.height + tRexBox.y > obstacleBox.y 465 | ) { 466 | crashed = true; 467 | } 468 | 469 | return crashed; 470 | } 471 | -------------------------------------------------------------------------------- /src/game/TrexGroup.js: -------------------------------------------------------------------------------- 1 | import Runner from './Runner'; 2 | import Trex, { checkForCollision } from './Trex'; 3 | 4 | export default class TrexGroup { 5 | onReset = noop; 6 | onRunning = noop; 7 | onCrash = noop; 8 | 9 | constructor(count, canvas, spriteDef) { 10 | this.tRexes = []; 11 | for (let i = 0; i < count; i += 1) { 12 | const tRex = new Trex(canvas, spriteDef); 13 | tRex.id = i; 14 | this.tRexes.push(tRex); 15 | } 16 | } 17 | 18 | update(deltaTime, status) { 19 | this.tRexes.forEach((tRex) => { 20 | if (!tRex.crashed) { 21 | tRex.update(deltaTime, status); 22 | } 23 | }); 24 | } 25 | 26 | draw(x, y) { 27 | this.tRexes.forEach((tRex) => { 28 | if (!tRex.crashed) { 29 | tRex.draw(x, y); 30 | } 31 | }); 32 | } 33 | 34 | updateJump(deltaTime, speed) { 35 | this.tRexes.forEach((tRex) => { 36 | if (tRex.jumping) { 37 | tRex.updateJump(deltaTime, speed); 38 | } 39 | }); 40 | } 41 | 42 | reset() { 43 | this.tRexes.forEach((tRex) => { 44 | tRex.reset(); 45 | this.onReset({ tRex }); 46 | }); 47 | } 48 | 49 | lives() { 50 | return this.tRexes.reduce((count, tRex) => tRex.crashed ? count : count + 1, 0); 51 | } 52 | 53 | checkForCollision(obstacle) { 54 | let crashes = 0; 55 | const state = { 56 | obstacleX: obstacle.xPos, 57 | obstacleY: obstacle.yPos, 58 | obstacleWidth: obstacle.width, 59 | speed: Runner.instance_.currentSpeed 60 | }; 61 | this.tRexes.forEach(async (tRex) => { 62 | if (!tRex.crashed) { 63 | const result = checkForCollision(obstacle, tRex); 64 | if (result) { 65 | crashes += 1; 66 | tRex.crashed = true; 67 | this.onCrash({ tRex, state }); 68 | } else { 69 | const action = await this.onRunning({ tRex, state }); 70 | if (action === 1) { 71 | tRex.startJump(); 72 | } else if (action === -1) { 73 | if (tRex.jumping) { 74 | // Speed drop, activated only when jump key is not pressed. 75 | tRex.setSpeedDrop(); 76 | } else if (!tRex.jumping && !tRex.ducking) { 77 | // Duck. 78 | tRex.setDuck(true); 79 | } 80 | } 81 | } 82 | } else { 83 | crashes += 1; 84 | } 85 | }); 86 | return crashes === this.tRexes.length; 87 | } 88 | } 89 | 90 | function noop() { } 91 | -------------------------------------------------------------------------------- /src/game/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Default game canvas size. 3 | */ 4 | export const CANVAS_WIDTH = 600; 5 | export const CANVAS_HEIGHT = 150; 6 | 7 | /** 8 | * Runner configs 9 | */ 10 | export const RUNNER_BOTTOM_PAD = 10; 11 | export const RUNNER_MAX_OBSTACLE_DUPLICATION = 2; 12 | -------------------------------------------------------------------------------- /src/game/images/offline-sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MagicCube/tensorflow-rex-run/eb0ea3658d7f959e41337aacf452f85fc608f12b/src/game/images/offline-sprite.png -------------------------------------------------------------------------------- /src/game/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MagicCube/tensorflow-rex-run/eb0ea3658d7f959e41337aacf452f85fc608f12b/src/game/images/splash.png -------------------------------------------------------------------------------- /src/game/index.js: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | 3 | export { default as Runner } from './Runner'; 4 | -------------------------------------------------------------------------------- /src/game/index.less: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | width: 100%; 6 | height: 100%; 7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 8 | } 9 | 10 | h1 { 11 | font-size: 18px; 12 | font-weight: bold; 13 | text-align: center; 14 | margin-top: 20px; 15 | margin-bottom: 20px; 16 | } 17 | 18 | /* Offline page */ 19 | .page { 20 | margin-top: 20px; 21 | overflow: hidden; 22 | 23 | .game { 24 | position: relative; 25 | color: #2b2b2b; 26 | font-size: 1em; 27 | margin: 0 auto; 28 | max-width: 600px; 29 | width: 100%; 30 | padding-top: 50px; 31 | } 32 | 33 | .game-canvas { 34 | height: 150px; 35 | max-width: 600px; 36 | opacity: 1; 37 | overflow: hidden; 38 | top: 0; 39 | z-index: 2; 40 | } 41 | 42 | .generation { 43 | position: absolute; 44 | top: 4px; 45 | left: 0; 46 | right: 0; 47 | font-weight: bold; 48 | text-align: center; 49 | } 50 | } 51 | 52 | @media (max-height: 350px) { 53 | .game { 54 | margin-top: 5%; 55 | } 56 | } 57 | 58 | @media (min-width: 600px) and (max-width: 736px) and (orientation: landscape) { 59 | .page .game { 60 | margin-left: 0; 61 | margin-right: 0; 62 | } 63 | } 64 | 65 | @media (max-width: 240px) { 66 | .game { 67 | overflow: inherit; 68 | padding: 0 8px; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/game/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the current timestamp. 3 | * @return {number} 4 | */ 5 | export function getTimeStamp() { 6 | return performance.now(); 7 | } 8 | 9 | /** 10 | * Get random number. 11 | * @param {number} min 12 | * @param {number} max 13 | * @param {number} 14 | */ 15 | export function getRandomNum(min, max) { 16 | return Math.floor(Math.random() * (max - min + 1)) + min; 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 2 | const path = require('path'); 3 | 4 | const ASSETS_SOURCE_PATH = path.resolve('./src'); 5 | const ASSETS_BUILD_PATH = path.resolve('./assets'); 6 | const ASSETS_PUBLIC_PATH = '/assets'; 7 | 8 | module.exports = { 9 | context: ASSETS_SOURCE_PATH, 10 | entry: { 11 | 'genetic': ['./apps/genetic.js'], 12 | 'genetic-nn': ['./apps/genetic-nn.js'], 13 | nn: ['./apps/nn.js'], 14 | nnm: ['./apps/nnm.js'], 15 | random: ['./apps/random.js'] 16 | }, 17 | output: { 18 | path: ASSETS_BUILD_PATH, 19 | publicPath: ASSETS_PUBLIC_PATH, 20 | filename: './[name].js' 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | enforce: 'pre', 26 | test: /\.jsx?$/, 27 | exclude: /node_modules|screen-capture/, 28 | loader: 'eslint-loader' 29 | }, 30 | { 31 | test: /\.js$/, 32 | exclude: /node_modules/, 33 | loader: 'babel-loader' 34 | }, 35 | { 36 | test: /\.less$/, 37 | exclude: /node_modules/, 38 | use: ['style-loader', 'css-loader', 'less-loader'] 39 | }, 40 | { 41 | test: /\.png$/, 42 | exclude: /node_modules/, 43 | use: [ 44 | { 45 | loader: 'url-loader', 46 | options: { 47 | limit: 8192, 48 | mimetype: 'image/png', 49 | name: 'images/[name].[ext]' 50 | } 51 | } 52 | ] 53 | } 54 | ] 55 | }, 56 | plugins: [new CleanWebpackPlugin([ASSETS_BUILD_PATH], { verbose: false })], 57 | optimization: { 58 | splitChunks: { 59 | cacheGroups: { 60 | vendor: { 61 | test: /node_modules/, 62 | chunks: 'initial', 63 | name: 'vendor', 64 | priority: 10, 65 | enforce: true 66 | } 67 | } 68 | } 69 | } 70 | }; 71 | --------------------------------------------------------------------------------