├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── Dispatcher.jsx ├── index.js ├── components │ ├── Bullets.jsx │ ├── Enemies.jsx │ ├── Points.jsx │ ├── Player.jsx │ └── SpaceInvaders.jsx ├── Constants.jsx ├── Actions.jsx └── Store.jsx ├── README.md ├── .gitignore └── package.json /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Swizec/space-invaders/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/Dispatcher.jsx: -------------------------------------------------------------------------------- 1 | 2 | import { Dispatcher } from 'flux'; 3 | 4 | let dispatcher = new Dispatcher(); 5 | 6 | export default dispatcher; 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import SpaceInvaders from "./components/SpaceInvaders"; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById("root") 8 | ); 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Space Invaders in React and d3.js 3 | 4 | This is a simple Space Invaders clone built using React and d3.js. It 5 | was used as an example for my talk at the 2015 HTML5DevConf. 6 | 7 | Based off of react-transform-boilerplate 8 | 9 | ## License 10 | 11 | CC0 (public domain) 12 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | dist# See https://help.github.com/ignore-files/ for more about ignoring files. 5 | 6 | # dependencies 7 | /node_modules 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "space-invaders", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "d3": "3.5.17", 7 | "flux": "^3.1.3", 8 | "react": "^16.2.0", 9 | "react-dom": "^16.2.0" 10 | }, 11 | "devDependencies": { 12 | "react-scripts": "1.0.17" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test --env=jsdom", 18 | "eject": "react-scripts eject" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Bullets.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react'; 3 | 4 | import Points from './Points'; 5 | 6 | export default class Bullets extends Component { 7 | render() { 8 | let pointsData = this.props.bullets.map((bullet) => { 9 | bullet.r = 1; 10 | bullet.fillOpacity = 1; 11 | bullet.color = 'red'; 12 | return bullet; 13 | }); 14 | 15 | return ( 16 | 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Enemies.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react'; 3 | 4 | import Points from './Points'; 5 | import { ENEMY_RADIUS } from '../Constants'; 6 | 7 | export default class Enemies extends Component { 8 | render() { 9 | let pointsData = this.props.enemies 10 | .filter((e) => e.alive) 11 | .map((enemy) => { 12 | enemy.r = ENEMY_RADIUS; 13 | return enemy; 14 | }); 15 | 16 | return ( 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Constants.jsx: -------------------------------------------------------------------------------- 1 | export const START_GAME = "start_game"; 2 | export const STOP_GAME = "stop_game"; 3 | export const TIME_TICK = "tick"; 4 | export const CHANGE_EVENT = "change"; 5 | export const GAME_OVER = "game_over"; 6 | 7 | export const EDGE = 10; 8 | 9 | export const PLAYER_MOVE = "player_move"; 10 | export const PLAYER_STOP = "player_stop"; 11 | export const PLAYER_MAX_SPEED = 30; 12 | export const PLAYER_SHOOT = "player_shoot"; 13 | 14 | export const MOUSE_TRIGGER = "mouse"; 15 | export const KEY_TRIGGER = "key"; 16 | 17 | export const BULLET_MAX_SPEED = 5; 18 | 19 | export const ENEMY_SHOTS_PER_MINUTE = 50; 20 | export const ENEMY_RADIUS = 5; 21 | export const MS_PER_FRAME = 16; 22 | -------------------------------------------------------------------------------- /src/components/Points.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react'; 3 | 4 | class Point extends Component { 5 | render() { 6 | return ( 7 | 14 | ); 15 | } 16 | }; 17 | 18 | export default class Points extends Component { 19 | render() { 20 | return ( 21 | 22 | {this.props.points.map((point) => { 23 | return (); 24 | })} 25 | 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Player.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import d3 from "d3"; 3 | 4 | import Actions from "../Actions"; 5 | 6 | export default class Player extends Component { 7 | componentDidMount() { 8 | let node = this.refs.player, 9 | drag = d3.behavior.drag(); 10 | 11 | drag.on("drag", () => { 12 | Actions.player_move(d3.event.dx, d3.event.dy); 13 | }); 14 | 15 | d3.select(node).call(drag); 16 | } 17 | 18 | render() { 19 | let position = "translate(" + this.props.x + ", " + this.props.y + ")"; 20 | 21 | return ( 22 | 23 | 29 | 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Actions.jsx: -------------------------------------------------------------------------------- 1 | import Dispatcher from "./Dispatcher"; 2 | import { 3 | START_GAME, 4 | STOP_GAME, 5 | TIME_TICK, 6 | PLAYER_MOVE, 7 | MOUSE_TRIGGER, 8 | KEY_TRIGGER, 9 | PLAYER_STOP, 10 | PLAYER_SHOOT, 11 | GAME_OVER 12 | } from "./Constants"; 13 | 14 | export default { 15 | start_game(width, height, N_enemies) { 16 | Dispatcher.dispatch({ 17 | actionType: START_GAME, 18 | width: width, 19 | height: height, 20 | N_enemies: N_enemies 21 | }); 22 | }, 23 | 24 | stop_game() { 25 | Dispatcher.dispatch({ 26 | actionType: STOP_GAME 27 | }); 28 | }, 29 | 30 | time_tick() { 31 | Dispatcher.dispatch({ 32 | actionType: TIME_TICK 33 | }); 34 | }, 35 | 36 | player_move(dx, dy) { 37 | Dispatcher.dispatch({ 38 | actionType: PLAYER_MOVE, 39 | dx: dx, 40 | dy: dy, 41 | type: MOUSE_TRIGGER 42 | }); 43 | }, 44 | 45 | player_key_move(dx, dy) { 46 | Dispatcher.dispatch({ 47 | actionType: PLAYER_MOVE, 48 | dx: dx, 49 | dy: dy, 50 | type: KEY_TRIGGER 51 | }); 52 | }, 53 | 54 | player_stop() { 55 | Dispatcher.dispatch({ 56 | actionType: PLAYER_STOP 57 | }); 58 | }, 59 | 60 | player_shoot() { 61 | Dispatcher.dispatch({ 62 | actionType: PLAYER_SHOOT 63 | }); 64 | }, 65 | 66 | game_over() { 67 | Dispatcher.dispatch({ 68 | actionType: GAME_OVER 69 | }); 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/components/SpaceInvaders.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Store from "../Store"; 4 | import Actions from "../Actions"; 5 | 6 | import Enemies from "./Enemies"; 7 | import Bullets from "./Bullets"; 8 | import Player from "./Player"; 9 | 10 | class SpaceInvaders extends Component { 11 | constructor() { 12 | super(); 13 | this.state = Store.getGameState(); 14 | } 15 | 16 | componentDidMount() { 17 | Store.addChangeListener(this._onChange); 18 | window.addEventListener("keydown", this.keydown); 19 | window.addEventListener("keyup", this.keyup); 20 | } 21 | 22 | componentWillUnmount() { 23 | Store.removeChangeListener(this._onChange); 24 | window.removeEventListener("keydown", this.keydown); 25 | window.addEventListener("keyup", this.keyup); 26 | } 27 | 28 | _onChange = () => { 29 | this.setState(Store.getGameState()); 30 | }; 31 | 32 | start_game = () => { 33 | Actions.start_game( 34 | this.props.width, 35 | this.props.height, 36 | this.props.initialEnemies 37 | ); 38 | }; 39 | 40 | keydown(event) { 41 | let key = event.key; 42 | 43 | switch (key) { 44 | case "ArrowRight": 45 | Actions.player_key_move(1, 0); 46 | break; 47 | case "ArrowLeft": 48 | Actions.player_key_move(-1, 0); 49 | break; 50 | case " ": 51 | Actions.player_shoot(); 52 | break; 53 | default: 54 | // no op 55 | } 56 | } 57 | 58 | keyup(event) { 59 | let key = event.key; 60 | 61 | switch (key) { 62 | case "ArrowRight": 63 | case "ArrowLeft": 64 | Actions.player_stop(); 65 | break; 66 | default: 67 | // no op 68 | } 69 | } 70 | 71 | render() { 72 | if (this.state.started) { 73 | return ( 74 | 75 | 76 | 77 | 78 | 79 | ); 80 | } else if (this.state.ended) { 81 | let endGameText = "Game over", 82 | explainerText = "You got shot by an invader or yourself"; 83 | 84 | if (!this.state.enemies.filter(e => e.alive).length) { 85 | endGameText = "You win!"; 86 | explainerText = 87 | "You shot all the invaders and saved the planet o/"; 88 | } 89 | 90 | return ( 91 |
92 |

{endGameText}

93 |

{explainerText}

94 |

95 | 101 |

102 |

Built for #HTML5DevConf 2015 by Swizec

103 |
104 | ); 105 | } else { 106 | return ( 107 |
108 |

Space Invaders

109 |

110 | Simple space invaders clone built with React and some 111 | d3.js.
112 | Arrow keys or mouse drag to move,{" "} 113 | <space> to shoot. 114 |

115 |

116 | 122 |

123 |

Built for #HTML5DevConf 2015 by Swizec

124 |
125 | ); 126 | } 127 | } 128 | } 129 | 130 | export default SpaceInvaders; 131 | -------------------------------------------------------------------------------- /src/Store.jsx: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | 3 | import Dispatcher from "./Dispatcher"; 4 | import Actions from "./Actions"; 5 | 6 | import { 7 | START_GAME, 8 | STOP_GAME, 9 | TIME_TICK, 10 | CHANGE_EVENT, 11 | EDGE, 12 | PLAYER_MOVE, 13 | PLAYER_STOP, 14 | PLAYER_SHOOT, 15 | MOUSE_TRIGGER, 16 | KEY_TRIGGER, 17 | PLAYER_MAX_SPEED, 18 | BULLET_MAX_SPEED, 19 | ENEMY_SHOTS_PER_MINUTE, 20 | ENEMY_RADIUS, 21 | MS_PER_FRAME 22 | } from "./Constants"; 23 | 24 | const EventEmitter = require("events").EventEmitter; 25 | 26 | let Data = { 27 | timer: null, 28 | ended: null, 29 | enemies: [], 30 | player: {}, 31 | bullets: [] 32 | }; 33 | 34 | function player_speed() { 35 | let multiplier = d3.ease("cubic-in-out")(Data.player.ticks_moving / 3); 36 | 37 | Data.player.ticks_moving += 1; 38 | 39 | return PLAYER_MAX_SPEED * multiplier; 40 | } 41 | 42 | function bullet_speed(bullet) { 43 | let multiplier = d3.ease("exp")(bullet.ticks_alive / BULLET_MAX_SPEED); 44 | 45 | return BULLET_MAX_SPEED * multiplier; 46 | } 47 | 48 | function shouldShoot() { 49 | let N_alive = Data.enemies.filter(e => e.alive).length, 50 | p = ENEMY_SHOTS_PER_MINUTE / N_alive / (MS_PER_FRAME * 60); 51 | 52 | return Math.random() <= p; 53 | } 54 | 55 | function hit(e) { 56 | let lx = e.x - e.w / 2, 57 | rx = e.x + e.w / 2, 58 | ty = e.y - e.h / 2, 59 | by = e.y + e.h / 2, 60 | b; 61 | 62 | for (let i = 0; i < Data.bullets.length; i++) { 63 | b = Data.bullets[i]; 64 | if (b.x >= lx && b.x <= rx && b.y >= ty && b.y <= by) { 65 | return true; 66 | } 67 | } 68 | 69 | return false; 70 | } 71 | 72 | class Store extends EventEmitter { 73 | constructor() { 74 | super(); 75 | 76 | this.x_scale = d3.scale.linear().domain([0, 1]); 77 | 78 | this.enemy_y = d3.scale.threshold().domain(d3.range(0, 1, 0.25)); 79 | } 80 | 81 | getGameState() { 82 | return { 83 | started: !!Data.timer, 84 | ended: !!Data.ended, 85 | enemies: Data.enemies, 86 | player: Data.player, 87 | bullets: Data.bullets 88 | }; 89 | } 90 | 91 | generateEnemy() { 92 | Data.enemies.push({ 93 | id: "invader-" + Data.enemies.length, 94 | alive: true, 95 | x: this.x_scale(Math.random()), 96 | y: this.enemy_y(Math.random()), 97 | w: ENEMY_RADIUS, 98 | h: ENEMY_RADIUS, 99 | speed: 1, 100 | vector: [1, 0] 101 | }); 102 | } 103 | 104 | startGame(width, height, N_enemies) { 105 | Data = Data = { 106 | timer: null, 107 | ended: null, 108 | enemies: [], 109 | player: {}, 110 | bullets: [] 111 | }; 112 | Data.width = width; 113 | Data.height = height; 114 | 115 | this.x_scale.rangeRound([EDGE, width - EDGE]); 116 | this.enemy_y.range( 117 | d3.range(0, 4, 0.25).map(i => EDGE + Math.round(i * height / 3)) 118 | ); 119 | 120 | d3.range(N_enemies).forEach(() => this.generateEnemy()); 121 | 122 | Data.player = { 123 | w: 50, 124 | h: 10, 125 | x: width / 2, 126 | y: height - EDGE, 127 | ticks_moving: 0 128 | }; 129 | 130 | Data.timer = setInterval(() => Actions.time_tick(), MS_PER_FRAME); 131 | } 132 | 133 | stopGame() { 134 | Data.timer = clearInterval(Data.timer); 135 | Data.ended = true; 136 | } 137 | 138 | advanceGameState() { 139 | Data.enemies = Data.enemies.filter(e => e.alive).map(e => { 140 | e.x = e.x + e.vector[0] * e.speed; 141 | e.y = e.y + e.vector[1] * e.speed; 142 | 143 | if (e.x <= EDGE || e.x >= Data.width - EDGE) { 144 | e.vector[0] = -e.vector[0]; 145 | } 146 | 147 | if (hit(e)) { 148 | e.alive = false; 149 | } 150 | 151 | if (shouldShoot()) { 152 | this.addBullet(e, [0, 1]); 153 | } 154 | 155 | return e; 156 | }); 157 | 158 | Data.bullets = Data.bullets 159 | .map(b => { 160 | b.ticks_alive += 1; 161 | 162 | b.x = b.x + b.vector[0] * bullet_speed(b); 163 | b.y = b.y + b.vector[1] * bullet_speed(b); 164 | 165 | return b; 166 | }) 167 | .filter( 168 | b => 169 | !( 170 | b.x <= EDGE || 171 | b.x >= Data.width - EDGE || 172 | b.y <= EDGE || 173 | b.y >= Data.height - EDGE 174 | ) 175 | ); 176 | 177 | if (hit(Data.player) || !Data.enemies.filter(e => e.alive).length) { 178 | this.stopGame(); 179 | } 180 | } 181 | 182 | movePlayer(dx, dy) { 183 | let p = Data.player; 184 | 185 | p.x += dx; 186 | p.y += dy; 187 | 188 | if (p.x - p.w / 2 <= EDGE || p.x + p.w / 2 >= Data.width - EDGE) { 189 | p.x -= dx; 190 | } 191 | 192 | if (p.y <= Data.height / 3 || p.y >= Data.height - EDGE) { 193 | p.y -= dy; 194 | } 195 | 196 | Data.player = p; 197 | } 198 | 199 | addBullet(origin, vector) { 200 | Data.bullets.push({ 201 | x: origin.x + vector[0] * 3, 202 | y: origin.y + vector[1] * 3 + origin.h * vector[1], 203 | vector: vector, 204 | ticks_alive: 0, 205 | id: "bullet-" + new Date().getTime() + "-" + Math.random() * 1000 206 | }); 207 | } 208 | 209 | addChangeListener(callback) { 210 | this.on(CHANGE_EVENT, callback); 211 | } 212 | 213 | removeChangeListener(callback) { 214 | this.on(CHANGE_EVENT, callback); 215 | } 216 | 217 | emitChange() { 218 | this.emit(CHANGE_EVENT); 219 | } 220 | } 221 | 222 | Dispatcher.register(function(action) { 223 | switch (action.actionType) { 224 | case TIME_TICK: 225 | store.advanceGameState(); 226 | store.emitChange(); 227 | break; 228 | 229 | case START_GAME: 230 | store.startGame(action.width, action.height, action.N_enemies); 231 | store.emitChange(); 232 | break; 233 | 234 | case STOP_GAME: 235 | store.stopGame(); 236 | store.emitChange(); 237 | break; 238 | 239 | case PLAYER_MOVE: 240 | if (action.type == MOUSE_TRIGGER) { 241 | store.movePlayer(action.dx, action.dy); 242 | } else { 243 | let speed = player_speed(); 244 | store.movePlayer(speed * action.dx, speed * action.dy); 245 | } 246 | break; 247 | 248 | case PLAYER_STOP: 249 | Data.player.ticks_moving = 0; 250 | break; 251 | 252 | case PLAYER_SHOOT: 253 | store.addBullet(Data.player, [0, -1]); 254 | break; 255 | 256 | default: 257 | // no op 258 | } 259 | }); 260 | 261 | let store = new Store(); 262 | 263 | export default store; 264 | --------------------------------------------------------------------------------