├── .gitignore
├── Readme.md
├── immutable-es6
├── images
│ └── danger.png
├── index.html
├── index.js
├── package.json
├── src
│ ├── game.js
│ ├── ui.js
│ └── util.js
└── styles
│ ├── averia.ttf
│ ├── base.css
│ └── reset.css
├── immutable
├── images
│ └── danger.png
├── index.html
├── index.js
├── package.json
├── src
│ ├── game.js
│ ├── ui.js
│ └── util.js
└── styles
│ ├── averia.ttf
│ ├── base.css
│ └── reset.css
└── mutable
├── images
└── danger.png
├── index.html
├── index.js
├── package.json
├── src
├── game.js
├── ui.js
└── util.js
└── styles
├── averia.ttf
├── base.css
└── reset.css
/.gitignore:
--------------------------------------------------------------------------------
1 | /immutable-es6/node_modules/
2 | /immutable/node_modules/
3 | /mutable/node_modules/
4 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # Minesweeper in React and immutable-js
2 |
3 | This is a functional, although not entirely complete version of Minesweeper in
4 | React with the game rules implemented with both immutable-js and straight
5 | JavaScript, using mutable objects and arrays.
6 |
7 | ## Run the immutable version
8 |
9 | ```sh
10 | cd immutable-es6
11 | npm start
12 | ```
13 |
14 | [http://localhost:9966](http://localhost:9966)
15 |
16 | ## Run the mutable version
17 |
18 | ```sh
19 | cd mutable-es6
20 | npm start
21 | ```
22 |
23 | [http://localhost:9965](http://localhost:9965)
24 |
25 | ## Run the immutable ES5 version
26 |
27 | ```sh
28 | cd immutable
29 | npm start
30 | ```
31 |
32 | [http://localhost:9967](http://localhost:9967)
33 |
34 | ## Benchmarking
35 |
36 | Part of the exercise of writing this app many times was to benchmark and
37 | compare. Add `?bench` to the end of the URLs, and the game will play itself by
38 | revealing every tile that does not have a mine, and print the full time in the
39 | console. This is not the world's most scientific benchmark, but if you hit
40 | refresh a few times, you get a picture of the average, and the difference is big
41 | enough to notice with a fairly small dataset.
42 | You will likely see the mutable version being the slowest and the immutable ES6
43 | version being the fastest.
44 |
45 | ## ES6
46 |
47 | The code is written with various ES6 features, supported by the Babel
48 | transpiler. There is also a pure ES5 version.
49 |
--------------------------------------------------------------------------------
/immutable-es6/images/danger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/react-sweeper/f75f171d0f943832bfafdd326843c81af3e4dc4f/immutable-es6/images/danger.png
--------------------------------------------------------------------------------
/immutable-es6/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Minesweeper
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/immutable-es6/index.js:
--------------------------------------------------------------------------------
1 | import {createGame, revealTile, isGameOver} from './src/game.js';
2 | import {createUI} from './src/ui.js';
3 | import {EventEmitter} from 'events';
4 | import {List} from 'immutable';
5 |
6 | const channel = new EventEmitter();
7 | const renderMinesweeper = createUI(channel);
8 | let game = createGame({cols: 16, rows: 16, mines: 48});
9 | let history = List([game]);
10 |
11 | function render() {
12 | renderMinesweeper(game, document.getElementById('board'));
13 | }
14 |
15 | channel.on('undo', () => {
16 | if (history.size > 1) {
17 | history = history.pop();
18 | game = history.last();
19 | render();
20 | }
21 | });
22 |
23 | channel.on('reveal', (tile) => {
24 | if (isGameOver(game)) { return; }
25 |
26 | const newGame = revealTile(game, tile);
27 |
28 | if (newGame !== game) {
29 | history = history.push(newGame);
30 | game = newGame;
31 | }
32 |
33 | render();
34 |
35 | if (isGameOver(game)) {
36 | // Wait for the final render to complete before alerting the user
37 | setTimeout(() => { alert('GAME OVER!'); }, 50);
38 | }
39 | });
40 |
41 | if (/bench/.test(window.location.href)) {
42 | console.time('Reveal all non-mine tiles');
43 | render();
44 | game.get('tiles').forEach(function (tile) {
45 | if (!tile.get('isMine')) {
46 | channel.emit('reveal', tile.get('id'));
47 | }
48 | });
49 | console.timeEnd('Reveal all non-mine tiles');
50 | } else {
51 | render();
52 | }
53 |
--------------------------------------------------------------------------------
/immutable-es6/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-sweeper",
3 | "version": "0.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "beefy index.js -- -t babelify --external react"
9 | },
10 | "author": "Christian Johansen (christian@cjohansen.no)",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "beefy": "~2.1.3",
14 | "babelify": "~5.0.3",
15 | "browserify": "~9.0.3"
16 | },
17 | "dependencies": {
18 | "immutable": "~3.7.2",
19 | "babel": "~4.6.1",
20 | "react": "~0.12.2"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/immutable-es6/src/game.js:
--------------------------------------------------------------------------------
1 | import {List,Map,fromJS} from 'immutable';
2 | import {partition, shuffle, repeat, keep, prop} from './util.js';
3 |
4 | function initTiles(rows, cols, mines) {
5 | return shuffle(repeat(mines, Map({isMine: true, isRevealed: false})).
6 | concat(repeat(rows * cols - mines, Map({isRevealed: false})))).
7 | map(function (tile, idx) {
8 | return tile.set('id', idx);
9 | });
10 | }
11 |
12 | function onWEdge(game, tile) {
13 | return tile % game.get('cols') === 0;
14 | }
15 |
16 | function onEEdge(game, tile) {
17 | return tile % game.get('cols') === game.get('cols') - 1;
18 | }
19 |
20 | function idx(game, tile) {
21 | if (tile < 0) { return null; }
22 | return game.getIn(['tiles', tile]) ? tile : null;
23 | }
24 |
25 | function nw(game, tile) {
26 | return onWEdge(game, tile) ? null : idx(game, tile - game.get('cols') - 1);
27 | }
28 |
29 | function n(game, tile) {
30 | return idx(game, tile - game.get('cols'));
31 | }
32 |
33 | function ne(game, tile) {
34 | return onEEdge(game, tile) ? null : idx(game, tile - game.get('cols') + 1);
35 | }
36 |
37 | function e(game, tile) {
38 | return onEEdge(game, tile) ? null : idx(game, tile + 1);
39 | }
40 |
41 | function se(game, tile) {
42 | return onEEdge(game, tile) ? null : idx(game, tile + game.get('cols') + 1);
43 | }
44 |
45 | function s(game, tile) {
46 | return idx(game, tile + game.get('cols'));
47 | }
48 |
49 | function sw(game, tile) {
50 | return onWEdge(game, tile) ? null : idx(game, tile + game.get('cols') - 1);
51 | }
52 |
53 | function w(game, tile) {
54 | return onWEdge(game, tile) ? null : idx(game, tile - 1);
55 | }
56 |
57 | const directions = [nw, n, ne, e, se, s, sw, w];
58 |
59 | function neighbours(game, tile) {
60 | return keep(directions, function (dir) {
61 | return game.getIn(['tiles', dir(game, tile)]);
62 | });
63 | }
64 |
65 | function getMineCount(game, tile) {
66 | var nbs = neighbours(game, tile);
67 | return nbs.filter(prop('isMine')).length;
68 | }
69 |
70 | function isMine(game, tile) {
71 | return game.getIn(['tiles', tile, 'isMine']);
72 | }
73 |
74 | function isSafe(game) {
75 | const tiles = game.get('tiles');
76 | const mines = tiles.filter(prop('isMine'));
77 | return mines.filter(prop('isRevealed')) === 0 &&
78 | tiles.length - mines.length === tiles.filter(prop('isRevealed')).length;
79 | }
80 |
81 | export function isGameOver(game) {
82 | return isSafe(game) || game.get('isDead');
83 | }
84 |
85 | function addThreatCount(game, tile) {
86 | return game.setIn(['tiles', tile, 'threatCount'], getMineCount(game, tile));
87 | }
88 |
89 | function revealAdjacentSafeTiles(game, tile) {
90 | if (isMine(game, tile)) {
91 | return game;
92 | }
93 | game = addThreatCount(game, tile).setIn(['tiles', tile, 'isRevealed'], true);
94 | if (game.getIn(['tiles', tile, 'threatCount']) === 0) {
95 | return keep(directions, function (dir) {
96 | return dir(game, tile);
97 | }).reduce(function (game, pos) {
98 | return !game.getIn(['tiles', pos, 'isRevealed']) ?
99 | revealAdjacentSafeTiles(game, pos) : game;
100 | }, game);
101 | }
102 | return game;
103 | }
104 |
105 | function attemptWinning(game) {
106 | return isSafe(game) ? game.set('isSafe', true) : game;
107 | }
108 |
109 | function revealMine(tile) {
110 | return tile.get('isMine') ? tile.set('isRevealed', true) : tile;
111 | }
112 |
113 | function revealMines(game) {
114 | return game.updateIn(['tiles'], function (tiles) {
115 | return tiles.map(revealMine);
116 | });
117 | }
118 |
119 | export function revealTile(game, tile) {
120 | const updated = !game.getIn(['tiles', tile]) ?
121 | game : game.setIn(['tiles', tile, 'isRevealed'], true);
122 | return isMine(updated, tile) ?
123 | revealMines(updated.set('isDead', true)) :
124 | attemptWinning(revealAdjacentSafeTiles(updated, tile));
125 | }
126 |
127 | export function createGame(options) {
128 | return fromJS({
129 | cols: options.cols,
130 | rows: options.rows,
131 | playingTime: 0,
132 | tiles: initTiles(options.rows, options.cols, options.mines)
133 | });
134 | }
135 |
--------------------------------------------------------------------------------
/immutable-es6/src/ui.js:
--------------------------------------------------------------------------------
1 | /*global React*/
2 | import {partition} from './util';
3 | const {render, createClass, createFactory} = React;
4 | const {div, button} = React.DOM;
5 |
6 | // A little wrapper around React to avoid the slightly clunky factory wrapper
7 | // around the component as well as the reliance on `this` to access props. The
8 | // wrqpper defines a component as simply a render function.
9 | function createComponent(render) {
10 | var component = createFactory(createClass({
11 | shouldComponentUpdate(newProps, newState) {
12 | // Simplified, this app only uses props
13 | return newProps.data !== this.props.data;
14 | },
15 |
16 | render() {
17 | return render.call(this, this.props.data);
18 | }
19 | }));
20 |
21 | return (data) => {
22 | return component({data: data});
23 | };
24 | }
25 |
26 | export function createUI(channel) {
27 | const Tile = createComponent((tile) => {
28 | if (tile.get('isRevealed')) {
29 | return div({className: 'tile' + (tile.get('isMine') ? ' mine' : '')},
30 | tile.get('threatCount') > 0 ? tile.get('threatCount') : '');
31 | }
32 | return div({
33 | className: 'tile',
34 | onClick: function () {
35 | channel.emit('reveal', tile.get('id'));
36 | }
37 | }, div({className: 'lid'}, ''));
38 | });
39 |
40 | const Row = createComponent((tiles) => {
41 | return div({className: 'row'}, tiles.map(Tile).toJS());
42 | });
43 |
44 | const Board = createComponent((game) => {
45 | return div({
46 | className: 'board'
47 | }, partition(game.get('cols'), game.get('tiles')).map(Row).toJS());
48 | });
49 |
50 | const UndoButton = createComponent(() => {
51 | return button({
52 | onClick: channel.emit.bind(channel, 'undo')
53 | }, 'Undo');
54 | });
55 |
56 | const Game = createComponent((game) => {
57 | return div({}, [Board(game), UndoButton()]);
58 | });
59 |
60 | return (data, container) => {
61 | render(Game(data), container);
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/immutable-es6/src/util.js:
--------------------------------------------------------------------------------
1 | import {fromJS, List, Map} from 'immutable';
2 |
3 | function partition(size, coll) {
4 | var res = [];
5 | for (var i = 0, l = coll.size || coll.length; i < l; i += size) {
6 | res.push(coll.slice(i, i + size));
7 | }
8 | return fromJS(res);
9 | }
10 |
11 | function identity(v) {
12 | return v;
13 | }
14 |
15 | function prop(n) {
16 | return function (object) {
17 | return object instanceof Map ? object.get(n) : object[n];
18 | };
19 | }
20 |
21 | function keep(list, pred) {
22 | return list.map(pred).filter(identity);
23 | }
24 |
25 | function repeat(n, val) {
26 | const res = [];
27 | while (n--) {
28 | res.push(val);
29 | }
30 | return List(res);
31 | }
32 |
33 | function shuffle(list) {
34 | return list.sort(function () { return Math.random() - 0.5; });
35 | }
36 |
37 | export {partition, identity, prop, keep, repeat, shuffle};
38 |
--------------------------------------------------------------------------------
/immutable-es6/styles/averia.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/react-sweeper/f75f171d0f943832bfafdd326843c81af3e4dc4f/immutable-es6/styles/averia.ttf
--------------------------------------------------------------------------------
/immutable-es6/styles/base.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Averia Sans Libre';
3 | font-style: normal;
4 | font-weight: 400;
5 | src: local('Averia Sans Libre Regular'), local('AveriaSansLibre-Regular'), url(averia.ttf) format('truetype');
6 | }
7 |
8 | body {
9 | background: #b5afa7 no-repeat 23px 30px;
10 | background-size: 380px;
11 | font-family: 'Averia Sans Libre', helvetica, Sans, sans-serif;
12 | }
13 |
14 | .watermark {
15 | position: absolute;
16 | width: 100%;
17 | z-index: -1;
18 | }
19 |
20 | #main {
21 | margin: 0 auto 0;
22 | width: 800px;
23 | }
24 |
25 | .toolbar {
26 | border: 10px solid #a49e96;
27 | border-radius: 10px;
28 | font-size: 18px;
29 | margin: 20px;
30 | background: #938d85;
31 | display: inline-block;
32 | }
33 |
34 | button {
35 | border: none;
36 | background: transparent;
37 | color: #000;
38 | font-weight: bold;
39 | outline: none;
40 | font-size: 16px;
41 | font-family: 'Averia Sans Libre', helvetica, Sans, sans-serif;
42 | }
43 |
44 | button.disabled {
45 | color: #666;
46 | font-weight: normal;
47 | }
48 |
49 | button:hover {
50 | cursor: pointer;
51 | text-decoration: underline;
52 | }
53 |
54 | button.disabled:hover {
55 | cursor: default;
56 | text-decoration: none;
57 | }
58 |
59 | .time {
60 | background: #736d65;
61 | display: inline-block;
62 | padding: 6px 10px;
63 | }
64 |
65 | .board {
66 | border: 10px solid #a49e96;
67 | border-radius: 10px;
68 | margin: 20px;
69 | background: #938d85;
70 | display: inline-block;
71 | }
72 |
73 | .tile {
74 | position: relative;
75 | border: 1px solid #504d49;
76 | width: 30px;
77 | height: 30px;
78 | float: left;
79 | color: #100d09;
80 | font-weight: bold;
81 | line-height: 30px;
82 | font-size: 20px;
83 | vertical-align: middle;
84 | text-align: center;
85 | }
86 |
87 | .threat2 {
88 | color: #500d09;
89 | }
90 |
91 | .threat3 {
92 | color: #700d09;
93 | }
94 |
95 | .threat4 {
96 | color: #900d09;
97 | }
98 |
99 | .threat5 {
100 | color: #b00d09;
101 | }
102 |
103 | .threat6 {
104 | color: #e00d09;
105 | }
106 |
107 | .threat7 {
108 | color: #f00d09;
109 | }
110 |
111 | .threat8 {
112 | color: #ff0000;
113 | }
114 |
115 | .lid {
116 | position: absolute;
117 | top: -1px;
118 | left: -1px;
119 | right: -1px;
120 | bottom: -1px;
121 | border: 2px outset #a49e96;
122 | background-image:
123 | radial-gradient(
124 | circle at top left,
125 | #a49e96,
126 | #605d59
127 | );
128 | }
129 |
130 | .lid:hover {
131 | background-image:
132 | radial-gradient(
133 | circle at bottom right,
134 | #a49e96,
135 | #605d59
136 | );
137 | }
138 |
139 | .mine { background: url(../images/danger.png); }
140 |
--------------------------------------------------------------------------------
/immutable-es6/styles/reset.css:
--------------------------------------------------------------------------------
1 | html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{border:0;font-size:100%;font:inherit;vertical-align:baseline;margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:none}table{border-collapse:collapse;border-spacing:0}
2 |
--------------------------------------------------------------------------------
/immutable/images/danger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/react-sweeper/f75f171d0f943832bfafdd326843c81af3e4dc4f/immutable/images/danger.png
--------------------------------------------------------------------------------
/immutable/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Minesweeper
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/immutable/index.js:
--------------------------------------------------------------------------------
1 | var createGame = require('./src/game').createGame;
2 | var revealTile = require('./src/game').revealTile;
3 | var isGameOver = require('./src/game').isGameOver;
4 | var createUI = require('./src/ui').createUI;
5 | var EventEmitter = require('events').EventEmitter;
6 | var List = require('immutable').List;
7 |
8 | var channel = new EventEmitter();
9 | var renderMinesweeper = createUI(channel);
10 | var game = createGame({cols: 16, rows: 16, mines: 48});
11 | var history = List([game]);
12 |
13 | function render() {
14 | renderMinesweeper(game, document.getElementById('board'));
15 | }
16 |
17 | channel.on('undo', function () {
18 | if (history.size > 1) {
19 | history = history.pop();
20 | game = history.last();
21 | render();
22 | }
23 | });
24 |
25 | channel.on('reveal', function (tile) {
26 | if (isGameOver(game)) { return; }
27 |
28 | var newGame = revealTile(game, tile);
29 |
30 | if (newGame !== game) {
31 | history = history.push(newGame);
32 | game = newGame;
33 | }
34 |
35 | render();
36 |
37 | if (isGameOver(game)) {
38 | // Wait for the final render to complete before alerting the user
39 | setTimeout(function () { alert('GAME OVER!'); }, 50);
40 | }
41 | });
42 |
43 | if (/bench/.test(window.location.href)) {
44 | console.time('Reveal all non-mine tiles');
45 | render();
46 | game.get('tiles').forEach(function (tile) {
47 | if (!tile.get('isMine')) {
48 | channel.emit('reveal', tile.get('id'));
49 | }
50 | });
51 | console.timeEnd('Reveal all non-mine tiles');
52 | } else {
53 | render();
54 | }
55 |
--------------------------------------------------------------------------------
/immutable/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-sweeper",
3 | "version": "0.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "beefy index.js 9967 -- --external react"
9 | },
10 | "author": "Christian Johansen (christian@cjohansen.no)",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "beefy": "~2.1.3",
14 | "browserify": "~9.0.3"
15 | },
16 | "dependencies": {
17 | "immutable": "~3.7.2",
18 | "react": "~0.13.3"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/immutable/src/game.js:
--------------------------------------------------------------------------------
1 | var List = require('immutable').List;
2 | var Map = require('immutable').Map;
3 | var fromJS = require('immutable').fromJS;
4 | var partition = require('./util').partition;
5 | var shuffle = require('./util').shuffle;
6 | var repeat = require('./util').repeat;
7 | var keep = require('./util').keep;
8 | var prop = require('./util').prop;
9 |
10 | function initTiles(rows, cols, mines) {
11 | return shuffle(repeat(mines, Map({isMine: true, isRevealed: false})).
12 | concat(repeat(rows * cols - mines, Map({isRevealed: false})))).
13 | map(function (tile, idx) {
14 | return tile.set('id', idx);
15 | });
16 | }
17 |
18 | function onWEdge(game, tile) {
19 | return tile % game.get('cols') === 0;
20 | }
21 |
22 | function onEEdge(game, tile) {
23 | return tile % game.get('cols') === game.get('cols') - 1;
24 | }
25 |
26 | function idx(game, tile) {
27 | if (tile < 0) { return null; }
28 | return game.getIn(['tiles', tile]) ? tile : null;
29 | }
30 |
31 | function nw(game, tile) {
32 | return onWEdge(game, tile) ? null : idx(game, tile - game.get('cols') - 1);
33 | }
34 |
35 | function n(game, tile) {
36 | return idx(game, tile - game.get('cols'));
37 | }
38 |
39 | function ne(game, tile) {
40 | return onEEdge(game, tile) ? null : idx(game, tile - game.get('cols') + 1);
41 | }
42 |
43 | function e(game, tile) {
44 | return onEEdge(game, tile) ? null : idx(game, tile + 1);
45 | }
46 |
47 | function se(game, tile) {
48 | return onEEdge(game, tile) ? null : idx(game, tile + game.get('cols') + 1);
49 | }
50 |
51 | function s(game, tile) {
52 | return idx(game, tile + game.get('cols'));
53 | }
54 |
55 | function sw(game, tile) {
56 | return onWEdge(game, tile) ? null : idx(game, tile + game.get('cols') - 1);
57 | }
58 |
59 | function w(game, tile) {
60 | return onWEdge(game, tile) ? null : idx(game, tile - 1);
61 | }
62 |
63 | var directions = [nw, n, ne, e, se, s, sw, w];
64 |
65 | function neighbours(game, tile) {
66 | return keep(directions, function (dir) {
67 | return game.getIn(['tiles', dir(game, tile)]);
68 | });
69 | }
70 |
71 | function getMineCount(game, tile) {
72 | var nbs = neighbours(game, tile);
73 | return nbs.filter(prop('isMine')).length;
74 | }
75 |
76 | function isMine(game, tile) {
77 | return game.getIn(['tiles', tile, 'isMine']);
78 | }
79 |
80 | function isSafe(game) {
81 | var tiles = game.get('tiles');
82 | var mines = tiles.filter(prop('isMine'));
83 | return mines.filter(prop('isRevealed')) === 0 &&
84 | tiles.length - mines.length === tiles.filter(prop('isRevealed')).length;
85 | }
86 |
87 | exports.isGameOver = function isGameOver(game) {
88 | return isSafe(game) || game.get('isDead');
89 | };
90 |
91 | function addThreatCount(game, tile) {
92 | return game.setIn(['tiles', tile, 'threatCount'], getMineCount(game, tile));
93 | }
94 |
95 | function revealAdjacentSafeTiles(game, tile) {
96 | if (isMine(game, tile)) {
97 | return game;
98 | }
99 | game = addThreatCount(game, tile).setIn(['tiles', tile, 'isRevealed'], true);
100 | if (game.getIn(['tiles', tile, 'threatCount']) === 0) {
101 | return keep(directions, function (dir) {
102 | return dir(game, tile);
103 | }).reduce(function (game, pos) {
104 | return !game.getIn(['tiles', pos, 'isRevealed']) ?
105 | revealAdjacentSafeTiles(game, pos) : game;
106 | }, game);
107 | }
108 | return game;
109 | }
110 |
111 | function attemptWinning(game) {
112 | return isSafe(game) ? game.set('isSafe', true) : game;
113 | }
114 |
115 | function revealMine(tile) {
116 | return tile.get('isMine') ? tile.set('isRevealed', true) : tile;
117 | }
118 |
119 | function revealMines(game) {
120 | return game.updateIn(['tiles'], function (tiles) {
121 | return tiles.map(revealMine);
122 | });
123 | }
124 |
125 | exports.revealTile = function revealTile(game, tile) {
126 | var updated = !game.getIn(['tiles', tile]) ?
127 | game : game.setIn(['tiles', tile, 'isRevealed'], true);
128 | return isMine(updated, tile) ?
129 | revealMines(updated.set('isDead', true)) :
130 | attemptWinning(revealAdjacentSafeTiles(updated, tile));
131 | };
132 |
133 | exports.createGame = function createGame(options) {
134 | return fromJS({
135 | cols: options.cols,
136 | rows: options.rows,
137 | playingTime: 0,
138 | tiles: initTiles(options.rows, options.cols, options.mines)
139 | });
140 | };
141 |
--------------------------------------------------------------------------------
/immutable/src/ui.js:
--------------------------------------------------------------------------------
1 | /*global React*/
2 | var partition = require('./util').partition;
3 | var div = React.DOM.div;
4 | var button = React.DOM.button;
5 |
6 | // A little wrapper around React to avoid the slightly clunky factory wrapper
7 | // around the component as well as the reliance on `this` to access props. The
8 | // wrqpper defines a component as simply a render function.
9 | function createComponent(render) {
10 | var component = React.createFactory(React.createClass({
11 | shouldComponentUpdate: function (newProps, newState) {
12 | // Simplified, this app only uses props
13 | return newProps.data !== this.props.data;
14 | },
15 |
16 | render: function() {
17 | return render.call(this, this.props.data);
18 | }
19 | }));
20 |
21 | return function (data) {
22 | return component({data: data});
23 | };
24 | }
25 |
26 | exports.createUI = function createUI(channel) {
27 | var Tile = createComponent(function (tile) {
28 | if (tile.get('isRevealed')) {
29 | return div({className: 'tile' + (tile.get('isMine') ? ' mine' : '')},
30 | tile.get('threatCount') > 0 ? tile.get('threatCount') : '');
31 | }
32 | return div({
33 | className: 'tile',
34 | onClick: function () {
35 | channel.emit('reveal', tile.get('id'));
36 | }
37 | }, div({className: 'lid'}, ''));
38 | });
39 |
40 | var Row = createComponent(function (tiles) {
41 | return div({className: 'row'}, tiles.map(Tile).toJS());
42 | });
43 |
44 | var Board = createComponent(function (game) {
45 | return div({
46 | className: 'board'
47 | }, partition(game.get('cols'), game.get('tiles')).map(Row).toJS());
48 | });
49 |
50 | var UndoButton = createComponent(function () {
51 | return button({
52 | onClick: channel.emit.bind(channel, 'undo')
53 | }, 'Undo');
54 | });
55 |
56 | var Game = createComponent(function (game) {
57 | return div({}, [Board(game), UndoButton()]);
58 | });
59 |
60 | return function (data, container) {
61 | React.render(Game(data), container);
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/immutable/src/util.js:
--------------------------------------------------------------------------------
1 | var fromJS = require('immutable').fromJS;
2 | var List = require('immutable').List;
3 | var Map = require('immutable').Map;
4 |
5 | function partition(size, coll) {
6 | var res = [];
7 | for (var i = 0, l = coll.size || coll.length; i < l; i += size) {
8 | res.push(coll.slice(i, i + size));
9 | }
10 | return fromJS(res);
11 | }
12 |
13 | function identity(v) {
14 | return v;
15 | }
16 |
17 | function prop(n) {
18 | return function (object) {
19 | return object instanceof Map ? object.get(n) : object[n];
20 | };
21 | }
22 |
23 | function keep(list, pred) {
24 | return list.map(pred).filter(identity);
25 | }
26 |
27 | function repeat(n, val) {
28 | var res = [];
29 | while (n--) {
30 | res.push(val);
31 | }
32 | return List(res);
33 | }
34 |
35 | function shuffle(list) {
36 | return list.sort(function () { return Math.random() - 0.5; });
37 | }
38 |
39 | module.exports = {
40 | partition: partition,
41 | identity: identity,
42 | prop: prop,
43 | keep: keep,
44 | repeat: repeat,
45 | shuffle: shuffle
46 | };
47 |
--------------------------------------------------------------------------------
/immutable/styles/averia.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/react-sweeper/f75f171d0f943832bfafdd326843c81af3e4dc4f/immutable/styles/averia.ttf
--------------------------------------------------------------------------------
/immutable/styles/base.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Averia Sans Libre';
3 | font-style: normal;
4 | font-weight: 400;
5 | src: local('Averia Sans Libre Regular'), local('AveriaSansLibre-Regular'), url(averia.ttf) format('truetype');
6 | }
7 |
8 | body {
9 | background: #b5afa7 no-repeat 23px 30px;
10 | background-size: 380px;
11 | font-family: 'Averia Sans Libre', helvetica, Sans, sans-serif;
12 | }
13 |
14 | .watermark {
15 | position: absolute;
16 | width: 100%;
17 | z-index: -1;
18 | }
19 |
20 | #main {
21 | margin: 0 auto 0;
22 | width: 800px;
23 | }
24 |
25 | .toolbar {
26 | border: 10px solid #a49e96;
27 | border-radius: 10px;
28 | font-size: 18px;
29 | margin: 20px;
30 | background: #938d85;
31 | display: inline-block;
32 | }
33 |
34 | button {
35 | border: none;
36 | background: transparent;
37 | color: #000;
38 | font-weight: bold;
39 | outline: none;
40 | font-size: 16px;
41 | font-family: 'Averia Sans Libre', helvetica, Sans, sans-serif;
42 | }
43 |
44 | button.disabled {
45 | color: #666;
46 | font-weight: normal;
47 | }
48 |
49 | button:hover {
50 | cursor: pointer;
51 | text-decoration: underline;
52 | }
53 |
54 | button.disabled:hover {
55 | cursor: default;
56 | text-decoration: none;
57 | }
58 |
59 | .time {
60 | background: #736d65;
61 | display: inline-block;
62 | padding: 6px 10px;
63 | }
64 |
65 | .board {
66 | border: 10px solid #a49e96;
67 | border-radius: 10px;
68 | margin: 20px;
69 | background: #938d85;
70 | display: inline-block;
71 | }
72 |
73 | .tile {
74 | position: relative;
75 | border: 1px solid #504d49;
76 | width: 30px;
77 | height: 30px;
78 | float: left;
79 | color: #100d09;
80 | font-weight: bold;
81 | line-height: 30px;
82 | font-size: 20px;
83 | vertical-align: middle;
84 | text-align: center;
85 | }
86 |
87 | .threat2 {
88 | color: #500d09;
89 | }
90 |
91 | .threat3 {
92 | color: #700d09;
93 | }
94 |
95 | .threat4 {
96 | color: #900d09;
97 | }
98 |
99 | .threat5 {
100 | color: #b00d09;
101 | }
102 |
103 | .threat6 {
104 | color: #e00d09;
105 | }
106 |
107 | .threat7 {
108 | color: #f00d09;
109 | }
110 |
111 | .threat8 {
112 | color: #ff0000;
113 | }
114 |
115 | .lid {
116 | position: absolute;
117 | top: -1px;
118 | left: -1px;
119 | right: -1px;
120 | bottom: -1px;
121 | border: 2px outset #a49e96;
122 | background-image:
123 | radial-gradient(
124 | circle at top left,
125 | #a49e96,
126 | #605d59
127 | );
128 | }
129 |
130 | .lid:hover {
131 | background-image:
132 | radial-gradient(
133 | circle at bottom right,
134 | #a49e96,
135 | #605d59
136 | );
137 | }
138 |
139 | .mine { background: url(../images/danger.png); }
140 |
--------------------------------------------------------------------------------
/immutable/styles/reset.css:
--------------------------------------------------------------------------------
1 | html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{border:0;font-size:100%;font:inherit;vertical-align:baseline;margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:none}table{border-collapse:collapse;border-spacing:0}
2 |
--------------------------------------------------------------------------------
/mutable/images/danger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/react-sweeper/f75f171d0f943832bfafdd326843c81af3e4dc4f/mutable/images/danger.png
--------------------------------------------------------------------------------
/mutable/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Minesweeper
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/mutable/index.js:
--------------------------------------------------------------------------------
1 | import {createGame, revealTile, isGameOver} from './src/game.js';
2 | import {createUI} from './src/ui.js';
3 | import {EventEmitter} from 'events';
4 | import {List} from 'immutable';
5 |
6 | const channel = new EventEmitter();
7 | const renderMinesweeper = createUI(channel);
8 | let game = createGame({cols: 16, rows: 16, mines: 48});
9 |
10 | function render() {
11 | renderMinesweeper(game, document.getElementById('board'));
12 | }
13 |
14 | channel.on('reveal', (tile) => {
15 | if (isGameOver(game)) { return; }
16 | revealTile(game, tile);
17 | render();
18 |
19 | if (isGameOver(game)) {
20 | // Wait for the final render to complete before alerting the user
21 | setTimeout(() => { alert('GAME OVER!'); }, 50);
22 | }
23 | });
24 |
25 | if (/bench/.test(window.location.href)) {
26 | console.time('Reveal all non-mine tiles');
27 | render();
28 | game.tiles.forEach(function (tile) {
29 | if (!tile.isMine) {
30 | channel.emit('reveal', tile.id);
31 | }
32 | });
33 | console.timeEnd('Reveal all non-mine tiles');
34 | } else {
35 | render();
36 | }
37 |
--------------------------------------------------------------------------------
/mutable/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-sweeper",
3 | "version": "0.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "beefy index.js 9965 -- -t babelify --external react"
9 | },
10 | "author": "Christian Johansen (christian@cjohansen.no)",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "beefy": "~2.1.3",
14 | "babelify": "~5.0.3",
15 | "browserify": "~9.0.3"
16 | },
17 | "dependencies": {
18 | "immutable": "~3.7.2",
19 | "babel": "~4.6.1",
20 | "react": "~0.12.2"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/mutable/src/game.js:
--------------------------------------------------------------------------------
1 | import {partition, shuffle, repeat, keep, prop} from './util.js';
2 |
3 | function initTiles(rows, cols, mines) {
4 | var tiles = shuffle(repeat(mines, function () { return {isMine: true}; }).
5 | concat(repeat(rows * cols - mines, function () { return {}; })));
6 | tiles.forEach(function (tile, idx) { tile.id = idx; });
7 | return tiles;
8 | }
9 |
10 | function onWEdge(game, tile) {
11 | return tile % game.cols === 0;
12 | }
13 |
14 | function onEEdge(game, tile) {
15 | return tile % game.cols === game.cols - 1;
16 | }
17 |
18 | function idx(game, tile) {
19 | if (tile < 0) { return null; }
20 | return game.tiles[tile] ? tile : null;
21 | }
22 |
23 | function nw(game, tile) {
24 | return onWEdge(game, tile) ? null : idx(game, tile - game.cols - 1);
25 | }
26 |
27 | function n(game, tile) {
28 | return idx(game, tile - game.cols);
29 | }
30 |
31 | function ne(game, tile) {
32 | return onEEdge(game, tile) ? null : idx(game, tile - game.cols + 1);
33 | }
34 |
35 | function e(game, tile) {
36 | return onEEdge(game, tile) ? null : idx(game, tile + 1);
37 | }
38 |
39 | function se(game, tile) {
40 | return onEEdge(game, tile) ? null : idx(game, tile + game.cols + 1);
41 | }
42 |
43 | function s(game, tile) {
44 | return idx(game, tile + game.cols);
45 | }
46 |
47 | function sw(game, tile) {
48 | return onWEdge(game, tile) ? null : idx(game, tile + game.cols - 1);
49 | }
50 |
51 | function w(game, tile) {
52 | return onWEdge(game, tile) ? null : idx(game, tile - 1);
53 | }
54 |
55 | const directions = [nw, n, ne, e, se, s, sw, w];
56 |
57 | function neighbours(game, tile) {
58 | return keep(directions, function (dir) {
59 | return game.tiles[dir(game, tile)];
60 | });
61 | }
62 |
63 | function getMineCount(game, tile) {
64 | return neighbours(game, tile).filter(prop('isMine')).length;
65 | }
66 |
67 | function isMine(game, tile) {
68 | return game.tiles[tile].isMine;
69 | }
70 |
71 | function isSafe(game) {
72 | const tiles = game.tiles;
73 | const mines = tiles.filter(prop('isMine'));
74 | return mines.filter(prop('isRevealed')) === 0 &&
75 | tiles.length - mines.length === tiles.filter(prop('isRevealed')).length;
76 | }
77 |
78 | export function isGameOver(game) {
79 | return isSafe(game) || game.isDead;
80 | }
81 |
82 | function addThreatCount(game, tile) {
83 | game.tiles[tile].threatCount = getMineCount(game, tile);
84 | }
85 |
86 | function revealAdjacentSafeTiles(game, tile) {
87 | if (isMine(game, tile)) { return; }
88 | addThreatCount(game, tile);
89 | game.tiles[tile].isRevealed = true;
90 |
91 | if (game.tiles[tile].threatCount === 0) {
92 | keep(directions, function (dir) {
93 | return dir(game, tile);
94 | }).forEach(function (pos) {
95 | if (!game.tiles[pos].isRevealed) {
96 | revealAdjacentSafeTiles(game, pos);
97 | }
98 | });
99 | }
100 | }
101 |
102 | function attemptWinning(game) {
103 | game.isSafe = isSafe(game);
104 | }
105 |
106 | function revealMine(tile) {
107 | tile.isRevealed = tile.isRevealed || tile.isMine;
108 | }
109 |
110 | function revealMines(game) {
111 | game.tiles.forEach(revealMine);
112 | }
113 |
114 | export function revealTile(game, tile) {
115 | if (game.tiles[tile]) {
116 | game.tiles[tile].isRevealed = true;
117 | }
118 |
119 | if (isMine(game, tile)) {
120 | game.isDead = true;
121 | revealMines(game);
122 | } else {
123 | revealAdjacentSafeTiles(game, tile);
124 | attemptWinning(game);
125 | }
126 | }
127 |
128 | export function createGame(options) {
129 | return {
130 | cols: options.cols,
131 | rows: options.rows,
132 | playingTime: 0,
133 | tiles: initTiles(options.rows, options.cols, options.mines)
134 | };
135 | }
136 |
--------------------------------------------------------------------------------
/mutable/src/ui.js:
--------------------------------------------------------------------------------
1 | /*global React*/
2 | import {partition} from './util';
3 | const {render, createClass, createFactory} = React;
4 | const {div, button} = React.DOM;
5 |
6 | // A little wrapper around React to avoid the slightly clunky factory wrapper
7 | // around the component as well as the reliance on `this` to access props. The
8 | // wrqpper defines a component as simply a render function.
9 | function createComponent(render) {
10 | var component = createFactory(createClass({
11 | // shouldComponentUpdate(newProps, newState) {
12 | // // Simplified, this app only uses props
13 | // return newProps.data !== this.props.data;
14 | // },
15 |
16 | render() {
17 | return render.call(this, this.props.data);
18 | }
19 | }));
20 |
21 | return (data) => {
22 | return component({data: data});
23 | };
24 | }
25 |
26 | export function createUI(channel) {
27 | const Tile = createComponent((tile) => {
28 | if (tile.isRevealed) {
29 | return div({className: 'tile' + (tile.isMine ? ' mine' : '')},
30 | tile.threatCount > 0 ? tile.threatCount : '');
31 | }
32 | return div({
33 | className: 'tile',
34 | onClick: function () {
35 | channel.emit('reveal', tile.id);
36 | }
37 | }, div({className: 'lid'}, ''));
38 | });
39 |
40 | const Row = createComponent((tiles) => {
41 | return div({className: 'row'}, tiles.map(Tile));
42 | });
43 |
44 | const Board = createComponent((game) => {
45 | return div({
46 | className: 'board'
47 | }, partition(game.cols, game.tiles).map(Row));
48 | });
49 |
50 | const UndoButton = createComponent(() => {
51 | return button({
52 | onClick: channel.emit.bind(channel, 'undo')
53 | }, 'Undo');
54 | });
55 |
56 | const Game = createComponent((game) => {
57 | return div({}, [Board(game), UndoButton()]);
58 | });
59 |
60 | return (data, container) => {
61 | render(Game(data), container);
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/mutable/src/util.js:
--------------------------------------------------------------------------------
1 | function partition(size, coll) {
2 | var res = [];
3 | for (var i = 0, l = coll.size || coll.length; i < l; i += size) {
4 | res.push(coll.slice(i, i + size));
5 | }
6 | return res;
7 | }
8 |
9 | function identity(v) {
10 | return v;
11 | }
12 |
13 | function prop(n) {
14 | return function (object) {
15 | return object[n];
16 | };
17 | }
18 |
19 | function keep(list, pred) {
20 | return list.map(pred).filter(identity);
21 | }
22 |
23 | function repeat(n, val) {
24 | const res = [];
25 | while (n--) {
26 | res.push(typeof val === 'function' ? val() : val);
27 | }
28 | return res;
29 | }
30 |
31 | function shuffle(list) {
32 | return list.sort(function () { return Math.random() - 0.5; });
33 | }
34 |
35 | export {partition, identity, prop, keep, repeat, shuffle};
36 |
--------------------------------------------------------------------------------
/mutable/styles/averia.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjohansen/react-sweeper/f75f171d0f943832bfafdd326843c81af3e4dc4f/mutable/styles/averia.ttf
--------------------------------------------------------------------------------
/mutable/styles/base.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Averia Sans Libre';
3 | font-style: normal;
4 | font-weight: 400;
5 | src: local('Averia Sans Libre Regular'), local('AveriaSansLibre-Regular'), url(averia.ttf) format('truetype');
6 | }
7 |
8 | body {
9 | background: #b5afa7 no-repeat 23px 30px;
10 | background-size: 380px;
11 | font-family: 'Averia Sans Libre', helvetica, Sans, sans-serif;
12 | }
13 |
14 | .watermark {
15 | position: absolute;
16 | width: 100%;
17 | z-index: -1;
18 | }
19 |
20 | #main {
21 | margin: 0 auto 0;
22 | width: 800px;
23 | }
24 |
25 | .toolbar {
26 | border: 10px solid #a49e96;
27 | border-radius: 10px;
28 | font-size: 18px;
29 | margin: 20px;
30 | background: #938d85;
31 | display: inline-block;
32 | }
33 |
34 | button {
35 | border: none;
36 | background: transparent;
37 | color: #000;
38 | font-weight: bold;
39 | outline: none;
40 | font-size: 16px;
41 | font-family: 'Averia Sans Libre', helvetica, Sans, sans-serif;
42 | }
43 |
44 | button.disabled {
45 | color: #666;
46 | font-weight: normal;
47 | }
48 |
49 | button:hover {
50 | cursor: pointer;
51 | text-decoration: underline;
52 | }
53 |
54 | button.disabled:hover {
55 | cursor: default;
56 | text-decoration: none;
57 | }
58 |
59 | .time {
60 | background: #736d65;
61 | display: inline-block;
62 | padding: 6px 10px;
63 | }
64 |
65 | .board {
66 | border: 10px solid #a49e96;
67 | border-radius: 10px;
68 | margin: 20px;
69 | background: #938d85;
70 | display: inline-block;
71 | }
72 |
73 | .tile {
74 | position: relative;
75 | border: 1px solid #504d49;
76 | width: 30px;
77 | height: 30px;
78 | float: left;
79 | color: #100d09;
80 | font-weight: bold;
81 | line-height: 30px;
82 | font-size: 20px;
83 | vertical-align: middle;
84 | text-align: center;
85 | }
86 |
87 | .threat2 {
88 | color: #500d09;
89 | }
90 |
91 | .threat3 {
92 | color: #700d09;
93 | }
94 |
95 | .threat4 {
96 | color: #900d09;
97 | }
98 |
99 | .threat5 {
100 | color: #b00d09;
101 | }
102 |
103 | .threat6 {
104 | color: #e00d09;
105 | }
106 |
107 | .threat7 {
108 | color: #f00d09;
109 | }
110 |
111 | .threat8 {
112 | color: #ff0000;
113 | }
114 |
115 | .lid {
116 | position: absolute;
117 | top: -1px;
118 | left: -1px;
119 | right: -1px;
120 | bottom: -1px;
121 | border: 2px outset #a49e96;
122 | background-image:
123 | radial-gradient(
124 | circle at top left,
125 | #a49e96,
126 | #605d59
127 | );
128 | }
129 |
130 | .lid:hover {
131 | background-image:
132 | radial-gradient(
133 | circle at bottom right,
134 | #a49e96,
135 | #605d59
136 | );
137 | }
138 |
139 | .mine { background: url(../images/danger.png); }
140 |
--------------------------------------------------------------------------------
/mutable/styles/reset.css:
--------------------------------------------------------------------------------
1 | html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{border:0;font-size:100%;font:inherit;vertical-align:baseline;margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:none}table{border-collapse:collapse;border-spacing:0}
2 |
--------------------------------------------------------------------------------