├── .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 |
12 |
13 |
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 |
12 |
13 |
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 |
12 |
13 |
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 | --------------------------------------------------------------------------------