├── demo.gif ├── src ├── style.scss ├── index.html └── index.js ├── README.md ├── webpack.config.js ├── config ├── webpack.prod.js ├── webpack.dev.js └── base.js ├── package.json └── LICENSE /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zedwang/RxJS-Breakout/HEAD/demo.gif -------------------------------------------------------------------------------- /src/style.scss: -------------------------------------------------------------------------------- 1 | #stage { 2 | background: salmon; 3 | } 4 | 5 | html, 6 | body { 7 | height: 100%; 8 | } 9 | 10 | body { 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RxJS-Breakout 2 | A game into to RxJS 3 | Like this: 4 | ![image](demo.gif) 5 | ### Install 6 | ``` 7 | npm install 8 | ``` 9 | ### Run 10 | ``` 11 | npm start 12 | ``` 13 | 14 | 打开浏览器输入```http://localhost:8000``` 15 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | let env = 'dev' 2 | console.log(process.env.NODE_ENV) 3 | if (process.env.NODE_ENV === 'production') { 4 | env = 'prod' 5 | module.exports = require('./config/webpack.'+ env) 6 | } else { 7 | module.exports = require('./config/webpack.'+ env) 8 | } -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Administrator on 2017/12/29 0029. 3 | */ 4 | const Webpack = require('webpack') 5 | const config = require('./base') 6 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 7 | 8 | config.devtool = 'source-map' 9 | config.plugins.push(new UglifyJsPlugin({ 10 | sourceMap: true 11 | })) 12 | 13 | module.exports = config -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Administrator on 2017/12/29 0029. 3 | */ 4 | const Webpack = require('webpack') 5 | const config = require('./base') 6 | 7 | config.devtool = 'inline-source-map' 8 | config.devServer = { 9 | contentBase: './dist', 10 | hot: true 11 | } 12 | config.plugins.push(new Webpack.NamedModulesPlugin()) 13 | config.plugins.push(new Webpack.HotModuleReplacementPlugin()) 14 | 15 | module.exports = config -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | A game intro to RxJS 8 | 9 | 10 |
11 |

A game intro to RxJS

12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AGameIntroToRxJS", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server", 8 | "build": "cross-env NODE_ENV=production webpack --config webpack.config.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "zed", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "babel-core": "^6.26.0", 15 | "babel-loader": "^7.1.2", 16 | "babel-preset-env": "^1.6.1", 17 | "clean-webpack-plugin": "^0.1.17", 18 | "cross-env": "^5.1.3", 19 | "css-loader": "^0.28.8", 20 | "html-webpack-plugin": "^2.30.1", 21 | "node-sass": "^4.7.2", 22 | "sass-loader": "^6.0.6", 23 | "style-loader": "^0.19.1", 24 | "uglifyjs-webpack-plugin": "^1.1.5", 25 | "webpack": "^3.10.0", 26 | "webpack-dev-middleware": "^2.0.2", 27 | "webpack-dev-server": "^2.10.1", 28 | "webpack-hot-middleware": "^2.21.0" 29 | }, 30 | "dependencies": { 31 | "koa": "^2.4.1", 32 | "rxjs": "^5.5.6" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Zed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Administrator on 2017/12/29 0029. 3 | */ 4 | const path = require('path') 5 | const Webpack = require('webpack') 6 | const HtmlWebpackPlugin = require('html-webpack-plugin') 7 | const CleanWebpackPlugin = require('clean-webpack-plugin') 8 | module.exports = { 9 | 10 | entry: { 11 | "app": "./src/index.js" 12 | }, 13 | 14 | output: { 15 | filename: '[name].boundle.js', 16 | path: path.resolve(__dirname,'../dist'), 17 | publicPath:'/' 18 | }, 19 | 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.scss$/, 24 | use: ["style-loader","css-loader","sass-loader"] 25 | 26 | }, 27 | { 28 | test: /\.js$/, 29 | exclude: /node_modules/, 30 | use: { 31 | loader: 'babel-loader', 32 | options: { 33 | babelrc: false, 34 | presets: ['babel-preset-env'] 35 | } 36 | } 37 | } 38 | ] 39 | }, 40 | plugins: [ 41 | new CleanWebpackPlugin('../dist'), 42 | new HtmlWebpackPlugin({ 43 | title:'A game intro to RxJS', 44 | template: 'src/index.html' 45 | }) 46 | ] 47 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rxjs/Rx' 2 | import './style.scss' 3 | 4 | /* Graphics */ 5 | 6 | const canvas = document.getElementById('stage'); 7 | const context = canvas.getContext('2d'); 8 | context.fillStyle = 'pink'; 9 | 10 | const PADDLE_WIDTH = 100; 11 | const PADDLE_HEIGHT = 20; 12 | 13 | const BALL_RADIUS = 10; 14 | 15 | const BRICK_ROWS = 5; 16 | const BRICK_COLUMNS = 7; 17 | const BRICK_HEIGHT = 20; 18 | const BRICK_GAP = 3; 19 | 20 | function drawTitle() { 21 | context.textAlign = 'center'; 22 | context.font = '24px Courier New'; 23 | context.fillText('rxjs breakout', canvas.width / 2, canvas.height / 2 - 24); 24 | } 25 | 26 | function drawControls() { 27 | context.textAlign = 'center'; 28 | context.font = '16px Courier New'; 29 | context.fillText('press [<] and [>] to play', canvas.width / 2, canvas.height / 2); 30 | } 31 | 32 | function drawGameOver(text) { 33 | context.clearRect(canvas.width / 4, canvas.height / 3, canvas.width / 2, canvas.height / 3); 34 | context.textAlign = 'center'; 35 | context.font = '24px Courier New'; 36 | context.fillText(text, canvas.width / 2, canvas.height / 2); 37 | } 38 | 39 | function drawAuthor() { 40 | context.textAlign = 'center'; 41 | context.font = '16px Courier New'; 42 | context.fillText('by Manuel Wieser', canvas.width / 2, canvas.height / 2 + 24); 43 | } 44 | 45 | function drawScore(score) { 46 | context.textAlign = 'left'; 47 | context.font = '16px Courier New'; 48 | context.fillText(score, BRICK_GAP, 16); 49 | } 50 | 51 | function drawPaddle(position) { 52 | context.beginPath(); 53 | context.rect( 54 | position - PADDLE_WIDTH / 2, 55 | context.canvas.height - PADDLE_HEIGHT, 56 | PADDLE_WIDTH, 57 | PADDLE_HEIGHT 58 | ); 59 | context.fill(); 60 | context.closePath(); 61 | } 62 | 63 | function drawBall(ball) { 64 | context.beginPath(); 65 | context.arc(ball.position.x, ball.position.y, BALL_RADIUS, 0, Math.PI * 2); 66 | context.fill(); 67 | context.closePath(); 68 | } 69 | 70 | function drawBrick(brick) { 71 | context.beginPath(); 72 | context.rect( 73 | brick.x - brick.width / 2, 74 | brick.y - brick.height / 2, 75 | brick.width, 76 | brick.height 77 | ); 78 | context.fill(); 79 | context.closePath(); 80 | } 81 | 82 | function drawBricks(bricks) { 83 | bricks.forEach((brick) => drawBrick(brick)); 84 | } 85 | 86 | 87 | /* Sounds */ 88 | 89 | const audio = new (window.AudioContext || window.webkitAudioContext)(); 90 | const beeper = new Rx.Subject(); 91 | beeper.subscribe((key) => { 92 | 93 | let oscillator = audio.createOscillator(); 94 | oscillator.connect(audio.destination); 95 | // 设置音频影调 96 | oscillator.type = 'square'; 97 | 98 | // https://en.wikipedia.org/wiki/Piano_key_frequencies 99 | // 设置音频频率 100 | oscillator.frequency.value = Math.pow(2, (key - 49) / 12) * 440; 101 | 102 | oscillator.start(); 103 | oscillator.stop(audio.currentTime + 0.100); 104 | 105 | }); 106 | 107 | 108 | /* Ticker */ 109 | 110 | const TICKER_INTERVAL = 17; 111 | 112 | const ticker$ = Rx.Observable 113 | .interval(TICKER_INTERVAL, Rx.Scheduler.requestAnimationFrame) 114 | .map(() => ({ 115 | time: Date.now(), 116 | deltaTime: null 117 | })) 118 | .scan( 119 | (previous, current) => ({ 120 | time: current.time, 121 | deltaTime: (current.time - previous.time) / 1000 122 | }) 123 | ); 124 | 125 | 126 | /* Paddle */ 127 | 128 | const PADDLE_SPEED = 240; 129 | const PADDLE_KEYS = { 130 | left: 37, 131 | right: 39 132 | }; 133 | 134 | const input$ = Rx.Observable 135 | .merge( 136 | Rx.Observable.fromEvent(document, 'keydown', event => { 137 | switch (event.keyCode) { 138 | case PADDLE_KEYS.left: 139 | return -1; 140 | case PADDLE_KEYS.right: 141 | return 1; 142 | default: 143 | return 0; 144 | } 145 | }), 146 | Rx.Observable.fromEvent(document, 'keyup', event => 0) 147 | ) 148 | .distinctUntilChanged(); 149 | 150 | const paddle$ = ticker$ 151 | .withLatestFrom(input$) 152 | .scan((position, [ticker, direction]) => { 153 | 154 | let next = position + direction * ticker.deltaTime * PADDLE_SPEED; 155 | return Math.max(Math.min(next, canvas.width - PADDLE_WIDTH / 2), PADDLE_WIDTH / 2); 156 | 157 | }, canvas.width / 2) 158 | .distinctUntilChanged(); 159 | 160 | 161 | /* Ball */ 162 | 163 | const BALL_SPEED = 60; 164 | const INITIAL_OBJECTS = { 165 | ball: { 166 | position: { 167 | x: canvas.width / 2, 168 | y: canvas.height / 2 169 | }, 170 | direction: { 171 | x: 2, 172 | y: 2 173 | } 174 | }, 175 | bricks: factory(), 176 | score: 0 177 | }; 178 | 179 | function hit(paddle, ball) { 180 | return ball.position.x > paddle - PADDLE_WIDTH / 2 181 | && ball.position.x < paddle + PADDLE_WIDTH / 2 182 | && ball.position.y > canvas.height - PADDLE_HEIGHT - BALL_RADIUS / 2; 183 | } 184 | 185 | const objects$ = ticker$ 186 | .withLatestFrom(paddle$) 187 | .scan(({ball, bricks, collisions, score}, [ticker, paddle]) => { 188 | 189 | let survivors = []; 190 | collisions = { 191 | paddle: false, 192 | floor: false, 193 | wall: false, 194 | ceiling: false, 195 | brick: false 196 | }; 197 | 198 | ball.position.x = ball.position.x + ball.direction.x * ticker.deltaTime * BALL_SPEED; 199 | ball.position.y = ball.position.y + ball.direction.y * ticker.deltaTime * BALL_SPEED; 200 | 201 | bricks.forEach((brick) => { 202 | if (!collision(brick, ball)) { 203 | survivors.push(brick); 204 | } else { 205 | collisions.brick = true; 206 | score = score + 10; 207 | } 208 | }); 209 | 210 | collisions.paddle = hit(paddle, ball); 211 | 212 | if (ball.position.x < BALL_RADIUS || ball.position.x > canvas.width - BALL_RADIUS) { 213 | ball.direction.x = -ball.direction.x; 214 | collisions.wall = true; 215 | } 216 | 217 | collisions.ceiling = ball.position.y < BALL_RADIUS; 218 | 219 | if (collisions.brick || collisions.paddle || collisions.ceiling ) { 220 | ball.direction.y = -ball.direction.y; 221 | } 222 | 223 | return { 224 | ball: ball, 225 | bricks: survivors, 226 | collisions: collisions, 227 | score: score 228 | }; 229 | 230 | }, INITIAL_OBJECTS); 231 | 232 | 233 | /* Bricks */ 234 | 235 | function factory() { 236 | let width = (canvas.width - BRICK_GAP - BRICK_GAP * BRICK_COLUMNS) / BRICK_COLUMNS; 237 | let bricks = []; 238 | 239 | for (let i = 0; i < BRICK_ROWS; i++) { 240 | for (let j = 0; j < BRICK_COLUMNS; j++) { 241 | bricks.push({ 242 | x: j * (width + BRICK_GAP) + width / 2 + BRICK_GAP, 243 | y: i * (BRICK_HEIGHT + BRICK_GAP) + BRICK_HEIGHT / 2 + BRICK_GAP + 20, 244 | width: width, 245 | height: BRICK_HEIGHT 246 | }); 247 | } 248 | } 249 | 250 | return bricks; 251 | } 252 | 253 | function collision(brick, ball) { 254 | return ball.position.x + ball.direction.x > brick.x - brick.width / 2 255 | && ball.position.x + ball.direction.x < brick.x + brick.width / 2 256 | && ball.position.y + ball.direction.y > brick.y - brick.height / 2 257 | && ball.position.y + ball.direction.y < brick.y + brick.height / 2; 258 | } 259 | 260 | 261 | /* Game */ 262 | 263 | drawTitle(); 264 | drawControls(); 265 | drawAuthor(); 266 | 267 | function update([ticker, paddle, objects]) { 268 | 269 | context.clearRect(0, 0, canvas.width, canvas.height); 270 | 271 | drawPaddle(paddle); 272 | drawBall(objects.ball); 273 | drawBricks(objects.bricks); 274 | drawScore(objects.score); 275 | 276 | if (objects.ball.position.y > canvas.height - BALL_RADIUS) { 277 | beeper.next(28); 278 | drawGameOver('GAME OVER'); 279 | game.unsubscribe(); 280 | } 281 | 282 | if (!objects.bricks.length) { 283 | beeper.next(52); 284 | drawGameOver('CONGRATULATIONS'); 285 | game.unsubscribe(); 286 | } 287 | 288 | if (objects.collisions.paddle) beeper.next(40); 289 | if (objects.collisions.wall || objects.collisions.ceiling) beeper.next(45); 290 | if (objects.collisions.brick) beeper.next(47 + Math.floor(objects.ball.position.y % 12)); 291 | 292 | } 293 | 294 | const game = Rx.Observable 295 | .combineLatest(ticker$, paddle$, objects$) 296 | .subscribe(update); 297 | --------------------------------------------------------------------------------