├── 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 |
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 |
--------------------------------------------------------------------------------