├── .gitignore ├── LICENSE ├── README.md ├── app.js ├── index.html └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | index.js 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Manuel Wieser 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RxJS Breakout 2 | 3 | [Breakout](https://en.wikipedia.org/wiki/Breakout_(video_game)) using [Functional Reactive Programming](https://gist.github.com/staltz/868e7e9bc2a7b8c1f754) and RxJS 4 | 5 | This is my first take on game programming with RxJS, so your feedback and pull requests are highly appreciated. 6 | 7 | ``` 8 | git clone https://github.com/Lorti/rxjs-breakout.git 9 | cd rxjs-breakout 10 | npm install 11 | npm start 12 | ``` 13 | 14 | ![Everything is a stream](https://camo.githubusercontent.com/e581baffb3db3e4f749350326af32de8d5ba4363/687474703a2f2f692e696d6775722e636f6d2f4149696d5138432e6a7067) 15 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 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.sample(100).subscribe((key) => { 92 | 93 | let oscillator = audio.createOscillator(); 94 | oscillator.connect(audio.destination); 95 | oscillator.type = 'square'; 96 | 97 | // https://en.wikipedia.org/wiki/Piano_key_frequencies 98 | oscillator.frequency.value = Math.pow(2, (key - 49) / 12) * 440; 99 | 100 | oscillator.start(); 101 | oscillator.stop(audio.currentTime + 0.100); 102 | 103 | }); 104 | 105 | 106 | /* Ticker */ 107 | 108 | const TICKER_INTERVAL = 17; 109 | 110 | const ticker$ = Rx.Observable 111 | .interval(TICKER_INTERVAL, Rx.Scheduler.requestAnimationFrame) 112 | .map(() => ({ 113 | time: Date.now(), 114 | deltaTime: null 115 | })) 116 | .scan( 117 | (previous, current) => ({ 118 | time: current.time, 119 | deltaTime: (current.time - previous.time) / 1000 120 | }) 121 | ); 122 | 123 | 124 | /* Paddle */ 125 | 126 | const PADDLE_SPEED = 240; 127 | const PADDLE_KEYS = { 128 | left: 37, 129 | right: 39 130 | }; 131 | 132 | const input$ = Rx.Observable 133 | .merge( 134 | Rx.Observable.fromEvent(document, 'keydown', event => { 135 | switch (event.keyCode) { 136 | case PADDLE_KEYS.left: 137 | return -1; 138 | case PADDLE_KEYS.right: 139 | return 1; 140 | default: 141 | return 0; 142 | } 143 | }), 144 | Rx.Observable.fromEvent(document, 'keyup', event => 0) 145 | ) 146 | .distinctUntilChanged(); 147 | 148 | const paddle$ = ticker$ 149 | .withLatestFrom(input$) 150 | .scan((position, [ticker, direction]) => { 151 | 152 | let next = position + direction * ticker.deltaTime * PADDLE_SPEED; 153 | return Math.max(Math.min(next, canvas.width - PADDLE_WIDTH / 2), PADDLE_WIDTH / 2); 154 | 155 | }, canvas.width / 2) 156 | .distinctUntilChanged(); 157 | 158 | 159 | /* Ball */ 160 | 161 | const BALL_SPEED = 60; 162 | const INITIAL_OBJECTS = { 163 | ball: { 164 | position: { 165 | x: canvas.width / 2, 166 | y: canvas.height / 2 167 | }, 168 | direction: { 169 | x: 2, 170 | y: 2 171 | } 172 | }, 173 | bricks: factory(), 174 | score: 0 175 | }; 176 | 177 | function hit(paddle, ball) { 178 | return ball.position.x > paddle - PADDLE_WIDTH / 2 179 | && ball.position.x < paddle + PADDLE_WIDTH / 2 180 | && ball.position.y > canvas.height - PADDLE_HEIGHT - BALL_RADIUS / 2; 181 | } 182 | 183 | const objects$ = ticker$ 184 | .withLatestFrom(paddle$) 185 | .scan(({ball, bricks, collisions, score}, [ticker, paddle]) => { 186 | 187 | let survivors = []; 188 | collisions = { 189 | paddle: false, 190 | floor: false, 191 | wall: false, 192 | ceiling: false, 193 | brick: false 194 | }; 195 | 196 | ball.position.x = ball.position.x + ball.direction.x * ticker.deltaTime * BALL_SPEED; 197 | ball.position.y = ball.position.y + ball.direction.y * ticker.deltaTime * BALL_SPEED; 198 | 199 | bricks.forEach((brick) => { 200 | if (!collision(brick, ball)) { 201 | survivors.push(brick); 202 | } else { 203 | collisions.brick = true; 204 | score = score + 10; 205 | } 206 | }); 207 | 208 | collisions.paddle = hit(paddle, ball); 209 | 210 | if (ball.position.x < BALL_RADIUS || ball.position.x > canvas.width - BALL_RADIUS) { 211 | ball.direction.x = -ball.direction.x; 212 | collisions.wall = true; 213 | } 214 | 215 | collisions.ceiling = ball.position.y < BALL_RADIUS; 216 | 217 | if (collisions.brick || collisions.paddle || collisions.ceiling ) { 218 | ball.direction.y = -ball.direction.y; 219 | } 220 | 221 | return { 222 | ball: ball, 223 | bricks: survivors, 224 | collisions: collisions, 225 | score: score 226 | }; 227 | 228 | }, INITIAL_OBJECTS); 229 | 230 | 231 | /* Bricks */ 232 | 233 | function factory() { 234 | let width = (canvas.width - BRICK_GAP - BRICK_GAP * BRICK_COLUMNS) / BRICK_COLUMNS; 235 | let bricks = []; 236 | 237 | for (let i = 0; i < BRICK_ROWS; i++) { 238 | for (let j = 0; j < BRICK_COLUMNS; j++) { 239 | bricks.push({ 240 | x: j * (width + BRICK_GAP) + width / 2 + BRICK_GAP, 241 | y: i * (BRICK_HEIGHT + BRICK_GAP) + BRICK_HEIGHT / 2 + BRICK_GAP + 20, 242 | width: width, 243 | height: BRICK_HEIGHT 244 | }); 245 | } 246 | } 247 | 248 | return bricks; 249 | } 250 | 251 | function collision(brick, ball) { 252 | return ball.position.x + ball.direction.x > brick.x - brick.width / 2 253 | && ball.position.x + ball.direction.x < brick.x + brick.width / 2 254 | && ball.position.y + ball.direction.y > brick.y - brick.height / 2 255 | && ball.position.y + ball.direction.y < brick.y + brick.height / 2; 256 | } 257 | 258 | 259 | /* Game */ 260 | 261 | drawTitle(); 262 | drawControls(); 263 | drawAuthor(); 264 | 265 | function update([ticker, paddle, objects]) { 266 | 267 | context.clearRect(0, 0, canvas.width, canvas.height); 268 | 269 | drawPaddle(paddle); 270 | drawBall(objects.ball); 271 | drawBricks(objects.bricks); 272 | drawScore(objects.score); 273 | 274 | if (objects.ball.position.y > canvas.height - BALL_RADIUS) { 275 | beeper.onNext(28); 276 | drawGameOver('GAME OVER'); 277 | game.dispose(); 278 | } 279 | 280 | if (!objects.bricks.length) { 281 | beeper.onNext(52); 282 | drawGameOver('CONGRATULATIONS'); 283 | game.dispose(); 284 | } 285 | 286 | if (objects.collisions.paddle) beeper.onNext(40); 287 | if (objects.collisions.wall || objects.collisions.ceiling) beeper.onNext(45); 288 | if (objects.collisions.brick) beeper.onNext(47 + Math.floor(objects.ball.position.y % 12)); 289 | 290 | } 291 | 292 | const game = Rx.Observable 293 | .combineLatest(ticker$, paddle$, objects$) 294 | .sample(TICKER_INTERVAL) 295 | .subscribe(update); 296 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RxJS Breakout 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxjs-breakout", 3 | "version": "1.0.0", 4 | "description": "RxJS Breakout Implementation", 5 | "scripts": { 6 | "start": "budo app.js:index.js --live --open -- -t [ babelify --presets [ es2015 ] ]" 7 | }, 8 | "author": { 9 | "name": "Manuel Wieser", 10 | "email": "office@manuelwieser.com", 11 | "url": "http://manu.ninja/" 12 | }, 13 | "license": "MIT", 14 | "devDependencies": { 15 | "babel-preset-es2015": "^6.6.0", 16 | "babelify": "^7.2.0", 17 | "browserify": "^13.0.0", 18 | "budo": "^8.1.0", 19 | "rx": "^4.1.0" 20 | } 21 | } 22 | --------------------------------------------------------------------------------