├── client ├── socket.js ├── gamePhase.js ├── utils │ ├── SetStateMixin.js │ └── domUtils.js ├── gameViews │ ├── board │ │ ├── Coordinate.jsx │ │ ├── GameShip.jsx │ │ ├── Ship.jsx │ │ └── Cell.jsx │ ├── GameOverView.jsx │ ├── SignInView.jsx │ ├── SetupView.jsx │ ├── ConfigPanel.jsx │ ├── SetupBoard.jsx │ ├── PlayBoard.jsx │ ├── Lobby.jsx │ └── ShootingView.jsx ├── stores │ ├── ConfigStore.js │ ├── UserStore.js │ ├── InvitationStore.js │ ├── LobbyStore.js │ ├── GameEventsStore.js │ ├── GamePhaseStore.js │ ├── GameboardStore.js │ └── SetupStore.js ├── actions.js ├── NavPanel.jsx ├── Game.jsx ├── ModalBox.jsx └── main.js ├── public ├── favicon.ico ├── images │ ├── logo.png │ ├── banner.png │ └── Battleship.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── mstile-70x70.png ├── apple-touch-icon.png ├── fonts │ ├── army-rust.eot │ ├── army-rust.ttf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ └── fontawesome-webfont.woff ├── mstile-150x150.png ├── mstile-310x150.png ├── android-chrome-36x36.png ├── android-chrome-48x48.png ├── android-chrome-72x72.png ├── android-chrome-96x96.png ├── apple-touch-icon-57x57.png ├── apple-touch-icon-60x60.png ├── apple-touch-icon-72x72.png ├── apple-touch-icon-76x76.png ├── apple-touch-icon-precomposed.png ├── browserconfig.xml ├── manifest.json └── stylesheets │ ├── bootstrap-import.less │ ├── reset.css │ ├── toast.min.css │ └── style.less ├── views ├── error.jade ├── index.jade └── layout.jade ├── game ├── models │ ├── position.js │ └── ship.js ├── Validator.js ├── gameEvents.js ├── messageHelper.js ├── battleships │ ├── Service.js │ ├── game.js │ └── Opponent.js ├── Lobby.js ├── BoardUtils.js └── LobbyService.js ├── routes └── index.js ├── test ├── spec │ ├── serverTestUtils.js │ ├── GameSpec.js │ ├── SocketServerGameSpec.js │ ├── OpponentSpec.js │ ├── SocketServerLobbySpec.js │ └── LobbySpec.js └── customMatchers.js ├── .gitignore ├── README.md ├── package.json ├── app.js ├── gulpfile.js └── LICENSE.md /client/socket.js: -------------------------------------------------------------------------------- 1 | module.exports = io(); 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/images/banner.png -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/fonts/army-rust.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/fonts/army-rust.eot -------------------------------------------------------------------------------- /public/fonts/army-rust.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/fonts/army-rust.ttf -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/images/Battleship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/images/Battleship.png -------------------------------------------------------------------------------- /public/android-chrome-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/android-chrome-36x36.png -------------------------------------------------------------------------------- /public/android-chrome-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/android-chrome-48x48.png -------------------------------------------------------------------------------- /public/android-chrome-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/android-chrome-72x72.png -------------------------------------------------------------------------------- /public/android-chrome-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/android-chrome-96x96.png -------------------------------------------------------------------------------- /public/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /public/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /public/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /views/error.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janekk/Battleships/HEAD/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /game/models/position.js: -------------------------------------------------------------------------------- 1 | function Position(x, y) { 2 | this.x = x; 3 | this.y = y; 4 | this.isDamaged = false; 5 | } 6 | 7 | module.exports = Position; -------------------------------------------------------------------------------- /client/gamePhase.js: -------------------------------------------------------------------------------- 1 | var gamePhase = { 2 | signIn: 0, 3 | inLobby: 10, 4 | setup: 20, 5 | readyToShoot: 40, 6 | gameMyTurn: 50, 7 | gameOpponentsTurn: 60, 8 | gameOver: 80 9 | }; 10 | 11 | module.exports = gamePhase; 12 | -------------------------------------------------------------------------------- /game/models/ship.js: -------------------------------------------------------------------------------- 1 | function Ship(id, positions) { 2 | this.id = id; 3 | this.positions = positions; 4 | this.healthCount = positions.length; // number of undamaged ship parts; on "0" the ship was destroyed 5 | } 6 | 7 | module.exports = Ship; -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | 4 | /* GET home page. */ 5 | router.get('/', function(req, res) { 6 | res.render('index', {title: 'Battleships - online game'}); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /client/utils/SetStateMixin.js: -------------------------------------------------------------------------------- 1 | var SetStateMixin = { 2 | 3 | setStateIfMounted: function(partialState, callback) { 4 | if(this.isMounted()) { 5 | this.setState(partialState, callback); 6 | } 7 | } 8 | }; 9 | 10 | module.exports = SetStateMixin; -------------------------------------------------------------------------------- /test/spec/serverTestUtils.js: -------------------------------------------------------------------------------- 1 | var utils = { 2 | getServer: function (http) { 3 | return require('../../game/socket-server')(http); 4 | }, 5 | 6 | getClient: function () { 7 | return require('socket.io-client')('http://localhost:3000', { 8 | forceNew: true 9 | }); 10 | } 11 | }; 12 | 13 | module.exports = utils; -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | #da532c 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /game/Validator.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | validateUserId: function(userId) { 3 | if(!(/^[A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF][A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF0-9]{3,16}$/).test(userId)) { 4 | return 'UserID has to be between 4 and 16 characters long and cannot start with a number!'; 5 | } 6 | 7 | if((userId == 'Computer') ) { 8 | return 'This UserID is not allowed'; 9 | } 10 | } 11 | }; -------------------------------------------------------------------------------- /client/gameViews/board/Coordinate.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var _ = require('lodash'); 3 | 4 | var Coordinate = React.createClass({ 5 | render() { 6 | var rectProps = { 7 | width: 10, 8 | height: 10, 9 | x: this.props.x * 10, 10 | y: this.props.y * 10 11 | }; 12 | return ( 13 | 14 | 15 | {this.props.text} 16 | 17 | ) 18 | } 19 | }); 20 | 21 | module.exports = Coordinate; -------------------------------------------------------------------------------- /client/stores/ConfigStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , gameEvents = require('../../game/gameEvents'); 4 | 5 | var ConfigStore = Reflux.createStore({ 6 | init() { 7 | 8 | this.state = null; 9 | 10 | socket.on(gameEvents.server.gameStarted, (result) => { 11 | if (result.isSuccessful) { 12 | this.state = result.config; 13 | this.trigger(this.state); 14 | } 15 | }); 16 | }, 17 | 18 | getState() { 19 | return this.state; 20 | } 21 | 22 | }); 23 | 24 | module.exports = ConfigStore; 25 | -------------------------------------------------------------------------------- /client/gameViews/GameOverView.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , Actions = require('../actions'); 3 | 4 | var GameOverView = React.createClass({ 5 | 6 | render() { 7 | var {props} = this; 8 | return ( 9 |
10 |

Game over!

11 |

{props.hasWon ? "You win!" : "You lose!"}

12 | 15 |
); 16 | } 17 | }); 18 | 19 | module.exports = GameOverView; 20 | -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | block content 3 | //div.g-plus(data-action="share" data-annotation="bubble" data-height="24") 4 | div#app 5 | block footer 6 | script(src="/scripts/bundle.js") 7 | script(src="//apis.google.com/js/platform.js" async defer) 8 | script(src="//platform.twitter.com/widgets.js" async defer) 9 | script. 10 | window.fbAsyncInit = function () { 11 | FB.init({ 12 | appId: '633605170101399', 13 | xfbml: true, 14 | version: 'v2.2' 15 | }); 16 | }; 17 | script(src="//connect.facebook.net/en_US/sdk.js" async="" defer="") -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "My app", 3 | "icons": [ 4 | { 5 | "src": "\/android-chrome-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-chrome-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-chrome-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-chrome-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /test/customMatchers.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | var customMatchers = { 4 | toContainWhere: function (expected) { 5 | var result = {}; 6 | 7 | var actualIsArray = Object.prototype.toString.apply(this.actual) === '[object Array]'; 8 | 9 | //Jasmine will look for this function and utilize it for custom error messages 10 | this.message = function () { 11 | if (actualIsArray) { 12 | return "Expected actual array to contain items like expected"; 13 | } 14 | return "Expected actual to be an Array"; 15 | }; 16 | 17 | if(actualIsArray) { 18 | return !!_.find(this.actual, expected); 19 | } 20 | return false; 21 | } 22 | } 23 | 24 | module.exports = customMatchers; -------------------------------------------------------------------------------- /public/stylesheets/bootstrap-import.less: -------------------------------------------------------------------------------- 1 | @import "../../node_modules/bootstrap/less/variables.less"; 2 | @import "../../node_modules/bootstrap/less/mixins.less"; 3 | 4 | // Reset and dependencies 5 | @import "../../node_modules/bootstrap/less/normalize.less"; 6 | //@import "../../node_modules/bootstrap/less/print.less"; 7 | //@import "glyphicons.less"; 8 | 9 | // Core CSS 10 | @import "../../node_modules/bootstrap/less/scaffolding.less"; 11 | //@import "../../node_modules/bootstrap/less/type.less"; 12 | //@import "../../node_modules/bootstrap/less/code.less"; 13 | //@import "../../node_modules/bootstrap/less/grid.less"; 14 | //@import "../../node_modules/bootstrap/less/tables.less"; 15 | //@import "../../node_modules/bootstrap/less/forms.less"; 16 | @import "../../node_modules/bootstrap/less/buttons.less"; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | .idea 31 | 32 | public/stylesheets/style.css 33 | public/scripts/bundle.js 34 | 35 | # gedit backup files 36 | *~ 37 | -------------------------------------------------------------------------------- /client/actions.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux'); 2 | 3 | var actions = { 4 | common: { 5 | error: Reflux.createAction() 6 | }, 7 | init: { 8 | showSignIn: Reflux.createAction(), 9 | signIn: Reflux.createAction(), 10 | playSingle: Reflux.createAction(), 11 | inviteUser: Reflux.createAction(), 12 | acceptInvitation: Reflux.createAction(), 13 | signOut: Reflux.createAction() 14 | 15 | }, 16 | setup: { 17 | selectConfigItem: Reflux.createAction(), 18 | selectShip: Reflux.createAction(), 19 | selectCell: Reflux.createAction(), 20 | pivotShip: Reflux.createAction(), 21 | placeShips: Reflux.createAction() 22 | }, 23 | game: { 24 | quit: Reflux.createAction(), 25 | shoot: Reflux.createAction(), 26 | initGameboard: Reflux.createAction() 27 | } 28 | }; 29 | 30 | module.exports = actions; 31 | 32 | -------------------------------------------------------------------------------- /test/spec/GameSpec.js: -------------------------------------------------------------------------------- 1 | var BattleshipsGame = require('../../game/battleships/game') 2 | , EventEmitter = require('events').EventEmitter 3 | , gameEvents = require('../../game/gameEvents'); 4 | 5 | var game, emitter = new EventEmitter(); 6 | 7 | describe('Battleship game', function () { 8 | 9 | beforeEach(function () { 10 | game = new BattleshipsGame(emitter, 'player1', 'player2'); 11 | }); 12 | 13 | it('doesn\'t allow to shoot if ships are not positioned', function(done) { 14 | 15 | //setup async with event emitter 16 | emitter.on('player1', function (event, result) { 17 | expect(event).toEqual(gameEvents.server.shotUpdate); 18 | expect(result.error).toBeTruthy(); 19 | expect(result.error).toEqual('Place ships first!'); 20 | done(); 21 | }); 22 | 23 | //run test 24 | game.shoot('player1', {x:0, y:5}); 25 | }); 26 | }); 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /client/gameViews/board/GameShip.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var _ = require('lodash'); 3 | 4 | var Ship = React.createClass({ 5 | render() { 6 | var {props} = this, cx = React.addons.classSet; 7 | 8 | var cells = []; 9 | props.ship.cells.forEach((cell) => { 10 | var rectProps = { 11 | width: 10, 12 | height: 10, 13 | x: cell.x * 10, 14 | y: cell.y * 10, 15 | key: cell.x + '.' + cell.y + '', 16 | className: cx({'hit': cell.isHit}) 17 | } 18 | cells.push(); 19 | }); 20 | 21 | var classes = cx({ 22 | 'ship': true, 23 | 'destroyed': props.isDestroyed, 24 | 'update': props.update 25 | }); 26 | 27 | return ( 28 | 29 | {cells} 30 | 31 | ) 32 | } 33 | }); 34 | module.exports = Ship; -------------------------------------------------------------------------------- /client/gameViews/SignInView.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , Actions = require('../actions') 3 | , phase = require('../gamePhase'); 4 | 5 | var SignInView = React.createClass({ 6 | 7 | signIn(e) { 8 | e.preventDefault(); 9 | var userName = this.refs.userName.getDOMNode().value; 10 | if (userName) { 11 | Actions.init.signIn(userName); 12 | } 13 | }, 14 | 15 | render() { 16 | return ( 17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 |
); 28 | } 29 | }); 30 | 31 | module.exports = SignInView; 32 | -------------------------------------------------------------------------------- /client/stores/UserStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , _ = require('lodash') 4 | , phase = require('../gamePhase') 5 | , Actions = require('../actions') 6 | , GamePhaseStore = require('./GamePhaseStore') 7 | , gameEvents = require('../../game/gameEvents'); 8 | 9 | var UserStore = Reflux.createStore({ 10 | init() { 11 | 12 | this.state = { 13 | signedIn: false, 14 | isPlaying: false, 15 | userId: null, 16 | opponentId: null 17 | }; 18 | 19 | this.listenTo(GamePhaseStore, this.setState); 20 | }, 21 | 22 | setState(game) { 23 | this.state.signedIn = game.phase >= phase.inLobby; 24 | this.state.isPlaying = (game.phase > phase.inLobby); 25 | this.state.opponentId = (game.phase > phase.inLobby) ? game.opponent.id : null; 26 | this.state.userId = (game.phase >= phase.inLobby) ? game.user.id : null; 27 | 28 | this.trigger(this.state); 29 | } 30 | }); 31 | 32 | module.exports = UserStore; 33 | -------------------------------------------------------------------------------- /client/gameViews/board/Ship.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons') 2 | , _ = require('lodash') 3 | , utils = require('../../utils/domUtils'); 4 | 5 | var Ship = React.createClass({ 6 | 7 | componentDidMount() { 8 | utils.addDoubleTapEvent(this.getDOMNode()); 9 | }, 10 | 11 | render() { 12 | var cells = []; 13 | this.props.ship.cells.forEach((cell) => { 14 | var rectProps = { 15 | width: 10, 16 | height: 10, 17 | x: cell.x * 10, 18 | y: cell.y * 10, 19 | key: cell.x + '.' + cell.y + '' 20 | } 21 | cells.push(); 22 | }); 23 | 24 | var cx = React.addons.classSet; 25 | var classes = cx({ 26 | 'ship': true, 27 | 'selected': this.props.selected, 28 | 'update': this.props.update 29 | }); 30 | 31 | return ( 32 | 33 | {cells} 34 | 35 | ) 36 | } 37 | }); 38 | 39 | module.exports = Ship; -------------------------------------------------------------------------------- /game/gameEvents.js: -------------------------------------------------------------------------------- 1 | var gameEvents = { 2 | client : { 3 | enterLobby: 'enter lobby', 4 | invitationRequest: 'invitation request', 5 | invitationResponse: 'invitation response', 6 | playSingle: 'play single', 7 | placeShips: 'place ships', 8 | shoot: 'shoot', 9 | quitGame: 'quit game', 10 | signOut: 'sign out' 11 | }, 12 | server : { 13 | enterLobbyStatus: 'enter lobby status', 14 | lobbyUpdate: 'lobby update', 15 | invitationRequestStatus: 'invitation request status', 16 | invitationForward: 'invitation forward', 17 | invitationResponse: 'invitation response', 18 | gameStarted: 'game started', 19 | shipsPlaced: 'ships placed', 20 | playerSwitched: 'player switched', 21 | activatePlayer: 'activate player', 22 | playerLeft: 'player left', 23 | infoMessage: 'info message', 24 | shotUpdate: 'shot update', 25 | gameOver: 'game over', 26 | quitGameStatus: 'quit game status', 27 | signOutStatus: 'sign out status' 28 | } 29 | }; 30 | 31 | module.exports = gameEvents; 32 | 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![alt battleship logo](https://github.com/Janekk/Janekk.github.io/blob/master/assets/posts/battleships_banner.png) 2 | 3 | Battleships 4 | =========== 5 | 6 | Battleships is a remake of the classic pen and pencil game brought to your browser. Game is round-based and can be played in single- or two-player mode. It was originally created to learn and explore a few libraries and technologies, some of which are: [ReactJS](https://github.com/facebook/react), Flux architecture (with [RefluxJS](https://github.com/spoike/refluxjs)), SVG, RWD, [sockets.io](https://github.com/Automattic/socket.io), Node.js. 7 | 8 | ### Features ### 9 | - purely browser game for a wide set of devices 10 | - responsive design 11 | - supports all major browsers 12 | - single-player mode available 13 | - game offers a lobby where users can invite each other to play together 14 | 15 | ## Roadmap ## 16 | - [x] Facebook integration (see branch: [fb-integration](../../tree/fb-integration)) 17 | - [ ] Firefox OS / Phone Gap App 18 | - [ ] Add more games on top of it 19 | 20 | [You can play Battleships here!](http://battleships.mobi/) 21 | 22 | [And the Facebook version here!](https://apps.facebook.com/battleshipsboardgame/) 23 | -------------------------------------------------------------------------------- /game/messageHelper.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | module.exports = { 4 | /** 5 | * Transform data to an result object with "isSuccessful" field. 6 | * @param {string|error|object} [data] - optional, if data is an error "isSuccessful" will set to FALSE otherwise "isSuccessful" is TRUE 7 | */ 8 | toResult: function(data) { 9 | var result; 10 | 11 | if (_.isUndefined(data)) { // no data 12 | result = { isSuccessful: true }; 13 | } 14 | else if (_.isString(data)) { // string 15 | result = { 16 | isSuccessful: true, 17 | message: data 18 | }; 19 | } 20 | else if (data instanceof Error) { // error 21 | result = { 22 | isSuccessful: false, 23 | error: data.message 24 | }; 25 | } 26 | else if (_.isObject(data)) { // object 27 | result = _.merge({ isSuccessful: true }, data); 28 | } 29 | else { // unsupported type 30 | throw new Error('type not supported', data); 31 | } 32 | 33 | return result; 34 | }, 35 | 36 | OK: function() { 37 | return this.toResult(); 38 | } 39 | }; -------------------------------------------------------------------------------- /public/stylesheets/reset.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, 2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 3 | a, abbr, acronym, address, big, cite, code, 4 | del, dfn, em, img, ins, kbd, q, s, samp, 5 | small, strike, strong, sub, sup, tt, var, 6 | b, u, i, center, 7 | dl, dt, dd, ol, ul, li, 8 | fieldset, form, label, legend, 9 | table, caption, tbody, tfoot, thead, tr, th, td, 10 | article, aside, canvas, details, embed, 11 | figure, figcaption, footer, header, hgroup, 12 | menu, nav, output, ruby, section, summary, 13 | time, mark, audio, video { 14 | margin: 0; 15 | padding: 0; 16 | border: 0; 17 | font-size: 100%; 18 | font: inherit; 19 | vertical-align: baseline; 20 | } 21 | /* HTML5 display-role reset for older browsers */ 22 | article, aside, details, figcaption, figure, 23 | footer, header, hgroup, menu, nav, section { 24 | display: block; 25 | } 26 | body { 27 | line-height: 1; 28 | } 29 | ol, ul { 30 | list-style: none; 31 | } 32 | blockquote, q { 33 | quotes: none; 34 | } 35 | blockquote:before, blockquote:after, 36 | q:before, q:after { 37 | content: ''; 38 | content: none; 39 | } 40 | table { 41 | border-collapse: collapse; 42 | border-spacing: 0; 43 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Battleships", 3 | "description": "Battleships browser game", 4 | "version": "0.9.1", 5 | "private": true, 6 | "engines": { 7 | "node": "0.10.x", 8 | "npm": "2.5.x" 9 | }, 10 | "main": "app.js", 11 | "scripts": { 12 | "start": "node app.js", 13 | "test": "jest" 14 | }, 15 | "dependencies": { 16 | "body-parser": "~1.8.4", 17 | "browserify": "~6.0.3", 18 | "cookie-parser": "~1.3.3", 19 | "debug": "~2.0.0", 20 | "express": "~4.9.8", 21 | "jade": "~1.6.0", 22 | "less-middleware": "^1.0.4", 23 | "lodash": "~2.4.1", 24 | "morgan": "~1.3.2", 25 | "react": "~0.12.2", 26 | "serve-favicon": "~2.1.7", 27 | "socket.io": "~1.2.1", 28 | "reflux": "~0.2.5", 29 | "bootstrap": "~3.3.2", 30 | "socket.io-client": "~1.2.1", 31 | "react-toastr": "git://github.com/Janekk/react-toastr.git#react-toast-tmp-rel" 32 | }, 33 | "devDependencies": { 34 | "browserify": "~6.0.3", 35 | "reactify": "andreypopp/reactify", 36 | "vinyl-source-stream": "~1.0.0", 37 | "gulp": "~3.8.11", 38 | "gulp-nodemon": "~1.0.5", 39 | "gulp-plumber": "~0.6.6", 40 | "gulp-less": "~2.0.3", 41 | "gulp-uglify": "~1.0.2", 42 | "gulp-if": "~1.2.5", 43 | "yargs": "~1.3.3", 44 | "vinyl-buffer": "~1.0.0", 45 | "gulp-run": "~1.6.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/NavPanel.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , Reflux = require('reflux') 3 | , Actions = require('./actions'); 4 | 5 | var NavPanel = React.createClass({ 6 | 7 | getDefaultProps: function () { 8 | return { 9 | show: false, 10 | signedIn: false, 11 | isPlaying: false, 12 | userId: null 13 | }; 14 | }, 15 | 16 | signOut(e) { 17 | e.preventDefault(); 18 | if(this.props.isPlaying) { 19 | window.modalBox.show("Are you sure you want to quit the game and sign out?", Actions.init.signOut, {declineText: 'Continue Game'}); 20 | } 21 | else { 22 | Actions.init.signOut(); 23 | } 24 | }, 25 | 26 | quit(e) { 27 | e.preventDefault(); 28 | window.modalBox.show("Are you sure you want to quit the game?", Actions.game.quit, {declineText: 'Continue Game'}); 29 | }, 30 | 31 | render() { 32 | var {props} = this; 33 | return ( 34 |
35 |
36 | 50 |
51 | ) 52 | } 53 | 54 | }); 55 | 56 | module.exports = NavPanel; -------------------------------------------------------------------------------- /client/gameViews/SetupView.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , Reflux = require('reflux') 3 | , Actions = require('../actions') 4 | , phase = require('../gamePhase') 5 | , SetupStore = require('../stores/SetupStore') 6 | , ConfigPanel = require('./ConfigPanel') 7 | , SetupBoard = require('./SetupBoard'); 8 | 9 | var SetupView = React.createClass({ 10 | mixins: [Reflux.listenTo(SetupStore, 'onSetupChange')], 11 | 12 | onSetupChange(setup) { 13 | this.setState({setup}); 14 | }, 15 | 16 | componentWillMount() { 17 | this.onSetupChange(SetupStore.getState()); 18 | }, 19 | 20 | placeShips() { 21 | Actions.setup.placeShips(); 22 | }, 23 | 24 | render() { 25 | var {state} = this; 26 | return ( 27 |
28 | {state.setup.config ? 29 |
30 |
31 | Place ships on the gameboard by selecting a ship and clicking on a target field. Double-click to pivot the ship. Ships can't be adjacent to each other! 32 |
33 |
34 |
35 | 39 |
40 | 41 |
42 | 43 |
: null} 44 |
); 45 | } 46 | }); 47 | 48 | module.exports = SetupView; 49 | -------------------------------------------------------------------------------- /test/spec/SocketServerGameSpec.js: -------------------------------------------------------------------------------- 1 | var io = require('socket.io-client'); 2 | var gameEvents = require('../game/gameEvents'); 3 | var utils = require('./serverTestUtils'); 4 | var http; 5 | 6 | describe('socket-server', function () { 7 | var server, client; 8 | 9 | beforeEach(function () { 10 | if(!this.server) { 11 | http = require('http').Server().listen(3000); 12 | this.server = utils.getServer(http); 13 | } 14 | if (client) { 15 | client.disconnect(); 16 | } 17 | client = utils.getClient(); 18 | }); 19 | 20 | afterEach(function() { 21 | if(http) { 22 | http.close(); 23 | } 24 | }); 25 | 26 | jasmine.getEnv().defaultTimeoutInterval = 5000; 27 | it('doesn\'t allow to place ships by unsigned user', function (done) { 28 | 29 | client.on(gameEvents.server.shipsPlaced, function (result) { 30 | expect(result.isSuccessful).toBe(false); 31 | done(); 32 | }); 33 | 34 | client.emit(gameEvents.client.placeShips, []); 35 | }); 36 | 37 | it('doesn\'t allow to shoot by unsigned user', function (done) { 38 | 39 | client.on(gameEvents.server.shotUpdate, function (result) { 40 | expect(result.isSuccessful).toBe(false); 41 | done(); 42 | }); 43 | 44 | client.emit(gameEvents.client.shoot, []); 45 | }); 46 | 47 | it('doesn\'t allow to place ships before game start', function (done) { 48 | 49 | client.on(gameEvents.server.shipsPlaced, function (result) { 50 | expect(result.isSuccessful).toBe(false); 51 | done(); 52 | }); 53 | 54 | client.emit(gameEvents.client.enterLobby, 'test user'); 55 | client.emit(gameEvents.client.placeShips, []); 56 | }); 57 | 58 | }); -------------------------------------------------------------------------------- /client/gameViews/board/Cell.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var _ = require('lodash'); 3 | 4 | var Cell = React.createClass({ 5 | 6 | render() { 7 | var {props} = this; 8 | var rectProps = { 9 | className: 'cell', 10 | width: 10, 11 | height: 10, 12 | x: props.x * 10, 13 | y: props.y * 10 14 | }; 15 | 16 | if (props.shot) { 17 | var shot = props.shot; 18 | var cx = React.addons.classSet; 19 | var classes = cx({ 20 | 'cell': true, 21 | 'shot': !!shot, 22 | 'update': this.props.update, 23 | //'hit': (shot && shot.isHit), 24 | 'adjacent': (shot && shot.isAdjacentToShip) 25 | }); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | return ( 38 | 39 | ) 40 | } 41 | }); 42 | 43 | 44 | var Hit = React.createClass({ 45 | 46 | render() { 47 | var {props} = this; 48 | var rectProps = { 49 | width: 10, 50 | height: 10, 51 | x: props.x * 10, 52 | y: props.y * 10 53 | }; 54 | 55 | var cx = React.addons.classSet; 56 | var classes = cx({ 57 | 'hit': true, 58 | 'update': this.props.update 59 | }); 60 | 61 | return (); 62 | } 63 | }); 64 | 65 | module.exports = {Cell, Hit}; -------------------------------------------------------------------------------- /client/stores/InvitationStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , Actions = require('../actions') 4 | , _ = require('lodash') 5 | , gameEvents = require('../../game/gameEvents'); 6 | 7 | var InvitationStore = Reflux.createStore({ 8 | 9 | init() { 10 | socket.on(gameEvents.server.invitationRequestStatus, (result) => { 11 | this.handleErrorResult(result); 12 | }); 13 | 14 | socket.on(gameEvents.server.invitationForward, (result) => { 15 | if (!this.handleErrorResult(result)) { 16 | this.trigger({ 17 | type: 'info', 18 | topic: 'invitation forward', 19 | invitation: result.invitation 20 | }) 21 | } 22 | }); 23 | 24 | socket.on(gameEvents.server.invitationResponse, (result) => { 25 | if (!this.handleErrorResult(result)) { 26 | this.trigger({ 27 | type: result.accepted ? 'success' : 'info', 28 | topic: 'invitation response', 29 | response: result.invitation 30 | }) 31 | } 32 | }); 33 | 34 | this.listenTo(Actions.init.inviteUser, this.inviteUser); 35 | this.listenTo(Actions.init.acceptInvitation, this.acceptInvitation); 36 | }, 37 | 38 | handleErrorResult(result) { 39 | if (!result.isSuccessful) { 40 | this.trigger({ 41 | type: 'error', 42 | message: result.error 43 | }); 44 | return true; 45 | } 46 | return false; 47 | }, 48 | 49 | inviteUser(userId) { 50 | socket.emit(gameEvents.client.invitationRequest, userId); 51 | }, 52 | 53 | acceptInvitation(accepted, receiverId, senderId) { 54 | var response = {accepted: accepted, invitation: {from: senderId, to: receiverId}}; 55 | socket.emit(gameEvents.client.invitationResponse, response); 56 | } 57 | }); 58 | 59 | module.exports = InvitationStore; 60 | 61 | -------------------------------------------------------------------------------- /game/battleships/Service.js: -------------------------------------------------------------------------------- 1 | var messageHelper = require('./../messageHelper') 2 | , _ = require('lodash') 3 | , gameEvents = require('./../gameEvents') 4 | , Game = require('./game') 5 | , EventEmitter = require('events').EventEmitter; 6 | 7 | function BattleshipsService(emitter, sockets) { 8 | if (sockets.length > 2) { 9 | throw new Error('Too many socket joined the game!'); 10 | } 11 | 12 | if (sockets.length <= 0) { 13 | throw new Error('At least one socket must join the game!'); 14 | } 15 | 16 | var singleMode = (sockets.length == 1); 17 | 18 | var clientEvents = [ 19 | gameEvents.client.placeShips, 20 | gameEvents.client.shoot 21 | ]; 22 | 23 | sockets.forEach(function (socket) { 24 | emitter.on(socket.username, function (event, data) { 25 | if(event != gameEvents.server.gameOver) { 26 | socket.emit(event, data); 27 | } 28 | }); 29 | 30 | clientEvents.forEach(function (event) { 31 | socket.on(event, function (data) { 32 | emitter.emit(event, socket.username, data); 33 | }); 34 | }); 35 | }); 36 | 37 | var game; 38 | if(singleMode) { 39 | var opponentName = 'Computer'; 40 | var opponent = new (require('../../game/battleships/Opponent'))(); 41 | opponent.bindEvents(opponentName, emitter); 42 | game = new Game(emitter, sockets[0].username, opponentName); 43 | } 44 | else { 45 | game = new Game(emitter, sockets[0].username, sockets[1].username); 46 | } 47 | 48 | this.start = function () { 49 | game.start(); 50 | } 51 | 52 | this.end = function () { 53 | sockets.forEach(function (socket) { 54 | emitter.removeAllListeners(); 55 | 56 | clientEvents.forEach(function (event) { 57 | socket.removeAllListeners(event); 58 | }); 59 | }); 60 | 61 | this.getSockets = function () { 62 | return sockets.slice(); 63 | } 64 | } 65 | } 66 | 67 | module.exports = BattleshipsService; -------------------------------------------------------------------------------- /client/stores/LobbyStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , Actions = require('../actions') 4 | , _ = require('lodash') 5 | , gameEvents = require('../../game/gameEvents') 6 | , phase = require('../gamePhase') 7 | , GamePhaseStore = require('./GamePhaseStore') 8 | , AppStore = require('./UserStore'); 9 | 10 | var LobbyStore = Reflux.createStore({ 11 | 12 | setInitialState() { 13 | this.state = { 14 | userId: null, 15 | users: [] 16 | } 17 | }, 18 | 19 | resetOnSignIn(game) { 20 | if (game.phase == phase.signIn) { 21 | this.setInitialState(); 22 | } 23 | }, 24 | 25 | setUser (appState) { 26 | this.state.userId = appState.userId; 27 | }, 28 | 29 | init() { 30 | this.setInitialState(); 31 | this.listenTo(GamePhaseStore, this.resetOnSignIn); 32 | this.listenTo(AppStore, this.setUser); 33 | 34 | socket.on(gameEvents.server.lobbyUpdate, (update) => { 35 | var {state} = this; 36 | update.users.forEach((user) => { 37 | user.hasInvited = !!_.find(update.invitations, {from: user.id, to: state.userId}); 38 | user.gotInvitation = !!_.find(update.invitations, {to: user.id, from: state.userId}); 39 | }); 40 | 41 | state.users = update.users; 42 | this.trigger(state); 43 | }); 44 | 45 | socket.on(gameEvents.server.invitationForward, (data) => { 46 | var {state} = this; 47 | var invitingUser = _.find(state.users, {id: data.invitation.from}); 48 | invitingUser.hasInvited = true; 49 | this.trigger(state); 50 | }); 51 | 52 | socket.on(gameEvents.server.invitationRequestStatus, (status) => { 53 | if (status.isSuccessful) { 54 | var {state} = this; 55 | var invitedUser = _.find(state.users, {id: status.invitation.to}); 56 | invitedUser.gotInvitation = true; 57 | this.trigger(state); 58 | } 59 | }); 60 | } 61 | }); 62 | 63 | module.exports = LobbyStore; 64 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | var favicon = require('serve-favicon'); 4 | var logger = require('morgan'); 5 | var cookieParser = require('cookie-parser'); 6 | var bodyParser = require('body-parser'); 7 | 8 | var routes = require('./routes/index'); 9 | 10 | var app = express(); 11 | var http = require('http').Server(app); 12 | var io = require('socket.io')(http); 13 | var LobbyService = require('./game/LobbyService'); 14 | var Game = require('./game/battleships/Service'); 15 | 16 | var lobby = new LobbyService(io, Game); 17 | lobby.start(); 18 | 19 | app.set('port', process.env.PORT || 3000); 20 | 21 | // view engine setup 22 | app.set('views', path.join(__dirname, 'views')); 23 | app.set('view engine', 'jade'); 24 | 25 | // uncomment after placing your favicon in /public 26 | app.use(favicon(__dirname + '/public/favicon.ico')); 27 | app.use(logger('dev')); 28 | app.use(bodyParser.json()); 29 | app.use(bodyParser.urlencoded({ extended: false })); 30 | app.use(cookieParser()); 31 | app.use(require('less-middleware')(path.join(__dirname, 'public'))); 32 | app.use(express.static(path.join(__dirname, 'public'))); 33 | 34 | app.use('/', routes); 35 | 36 | // catch 404 and forward to error handler 37 | app.use(function(req, res, next) { 38 | var err = new Error('Not Found'); 39 | err.status = 404; 40 | next(err); 41 | }); 42 | 43 | // error handlers 44 | 45 | // development error handler 46 | // will print stacktrace 47 | if (app.get('env') === 'development') { 48 | app.use(function(err, req, res, next) { 49 | res.status(err.status || 500); 50 | res.render('error', { 51 | message: err.message, 52 | error: err 53 | }); 54 | }); 55 | } 56 | 57 | // production error handler 58 | // no stacktraces leaked to user 59 | app.use(function(err, req, res, next) { 60 | res.status(err.status || 500); 61 | res.render('error', { 62 | message: err.message, 63 | error: {} 64 | }); 65 | }); 66 | 67 | var server = http.listen(app.get('port'), function() { 68 | console.log('Express server listening on port ' + server.address().port); 69 | }); 70 | -------------------------------------------------------------------------------- /client/Game.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , Reflux = require('reflux') 3 | , _ = require('lodash') 4 | , Actions = require('./actions') 5 | , phase = require('./gamePhase') 6 | , GamePhaseStore = require('./stores/GamePhaseStore') 7 | , GameEventsStore = require('./stores/GameEventsStore') 8 | , LobbyView = require('./gameViews/Lobby') 9 | , SignInView = require('./gameViews/SignInView') 10 | , SetupView = require('./gameViews/SetupView') 11 | , ShootingView = require('./gameViews/ShootingView') 12 | , GameOverView = require('./gameViews/GameOverView'); 13 | 14 | var Game = React.createClass({ 15 | mixins: [Reflux.ListenerMixin], 16 | 17 | componentDidMount() { 18 | this.listenTo(GamePhaseStore, this.switchGameState); 19 | this.listenTo(Actions.common.error, function (message) { 20 | this.showGameEvent({type: 'error', message}); 21 | }); 22 | this.listenTo(GameEventsStore, this.showGameEvent); 23 | }, 24 | 25 | showGameEvent(event) { 26 | window.toastr[event.type](event.message, event.header, { 27 | showAnimation: 'animated fadeIn', 28 | hideAnimation: 'animated fadeOut' 29 | }); 30 | }, 31 | 32 | switchGameState(state) { 33 | if (state.phase != phase.gameMyTurn && state.phase != phase.gameOpponentsTurn) { 34 | this.setState(state); 35 | } 36 | }, 37 | 38 | render() { 39 | var panel, {state} = this; 40 | if (state) { 41 | switch (state.phase) { 42 | case phase.signIn: 43 | panel = (); 44 | break; 45 | case phase.inLobby: 46 | panel = (); 47 | break; 48 | case phase.setup: 49 | panel = (); 50 | break; 51 | case phase.readyToShoot: 52 | panel = (); 53 | break; 54 | case phase.gameOver: 55 | panel = (); 56 | break; 57 | } 58 | } 59 | 60 | return ( 61 |
62 | {panel} 63 |
64 | ); 65 | } 66 | }); 67 | 68 | module.exports = Game; 69 | -------------------------------------------------------------------------------- /client/ModalBox.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , utils = require('./utils/domUtils'); 3 | 4 | var ModalBox = React.createClass({ 5 | 6 | getInitialState() { 7 | return { 8 | show: false, 9 | question: null, 10 | confirmText: 'Yes', 11 | declineText: 'No', 12 | action: null 13 | } 14 | }, 15 | 16 | componentDidMount: function() { 17 | window.addEventListener("keydown", this.closeOnEscape); 18 | }, 19 | componentWillUnmount: function() { 20 | window.removeEventListener("keydown", this.closeOnEscape); 21 | }, 22 | 23 | confirm() { 24 | this.setState(this.getInitialState()); 25 | this.state.action(); 26 | }, 27 | 28 | decline() { 29 | this.setState(this.getInitialState()); 30 | }, 31 | 32 | show(question, action, opts) { 33 | var {state} = this; 34 | if (question) { 35 | var confirmText = (opts && opts.confirmText) ? opts.confirmText: state.confirmText; 36 | var declineText = (opts && opts.declineText) ? opts.declineText: state.declineText; 37 | this.setState({show: true, question, action, confirmText, declineText}); 38 | } 39 | }, 40 | 41 | closeOnEscape(e) { 42 | if(e.keyCode == 27) { 43 | this.decline(); 44 | } 45 | }, 46 | 47 | onOverlayClick(e) { 48 | if(!utils.isElementChildOf(e.target, this.refs.box.getDOMNode())) { 49 | this.decline(); 50 | } 51 | }, 52 | 53 | render() { 54 | var {state} = this; 55 | return ( 56 |
57 | {state.show ? 58 |
59 |
{state.question}
60 |
61 | 62 | 63 |
64 |
: null 65 | } 66 |
67 | ) 68 | } 69 | }); 70 | 71 | module.exports = ModalBox; -------------------------------------------------------------------------------- /client/utils/domUtils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isElementChildOf(c, p) { 3 | while ((c = c.parentNode) && c !== p); 4 | return !!c 5 | }, 6 | 7 | addDoubleTapEvent(elem, speed, distance) { 8 | if (!('ontouchstart' in elem)) { 9 | // non-touch has native dblclick and no need for polyfill 10 | return; 11 | } 12 | 13 | // default dblclick speed to half sec 14 | speed = Math.abs(+speed) || 500;//ms 15 | // default dblclick distance to within 40x40 area 16 | distance = Math.abs(+distance) || 40;//px 17 | 18 | var taps, x, y, 19 | reset = function () { 20 | // reset state 21 | taps = 0; 22 | x = NaN; 23 | y = NaN; 24 | }; 25 | 26 | reset(); 27 | 28 | elem.addEventListener('touchstart', function (e) { 29 | var touch = e.changedTouches[0] || {}, 30 | oldX = x, 31 | oldY = y; 32 | 33 | taps++; 34 | x = +touch.pageX || +touch.clientX || +touch.screenX; 35 | y = +touch.pageY || +touch.clientY || +touch.screenY; 36 | 37 | // NaN will always be false 38 | if (Math.abs(oldX - x) < distance && 39 | Math.abs(oldY - y) < distance) { 40 | 41 | // fire dblclick event 42 | var e2 = document.createEvent('MouseEvents'); 43 | if (e2.initMouseEvent) { 44 | e2.initMouseEvent( 45 | 'dblclick', 46 | true, // dblclick bubbles 47 | true, // dblclick cancelable 48 | e.view, // copy view 49 | taps, // click count 50 | touch.screenX, // copy coordinates 51 | touch.screenY, 52 | touch.clientX, 53 | touch.clientY, 54 | e.ctrlKey, // copy key modifiers 55 | e.altKey, 56 | e.shiftKey, 57 | e.metaKey, 58 | e.button, // copy button 0: left, 1: middle, 2: right 59 | touch.target); // copy target 60 | } 61 | elem.dispatchEvent(e2); 62 | } 63 | 64 | setTimeout(reset, speed); 65 | 66 | }, false); 67 | 68 | elem.addEventListener('touchmove', function (e) { 69 | reset(); 70 | }, false); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /client/gameViews/ConfigPanel.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , _ = require('lodash') 3 | , Actions = require('./../actions'); 4 | 5 | var ConfigPanel = React.createClass({ 6 | 7 | handleItemClick(item) { 8 | if(item.count > 0) { 9 | Actions.setup.selectConfigItem(item); 10 | } 11 | }, 12 | 13 | render() { 14 | var selected, {setup} = this.props; 15 | 16 | if(setup.selected) { 17 | if (setup.selected.type == 'config') { 18 | selected = setup.selected.item; 19 | } 20 | else { 21 | selected = null; 22 | } 23 | } 24 | 25 | var config = setup.config ? _.sortBy(setup.config.ships, (cfg) => {return -cfg.size;}) : []; 26 | var selectedSize = selected ? selected.size : null; 27 | 28 | var components = []; 29 | config.forEach((cfg, index) => { 30 | var handleClick = this.handleItemClick.bind(this, cfg); 31 | components.push(); 32 | }); 33 | 34 | var svgViewbox = [0, 0, 120, (config.length * 10) + 10]; 35 | return ( 36 |
37 | 38 | {components} 39 | 40 |
41 | ); 42 | } 43 | }); 44 | 45 | var ConfigurationShip = React.createClass({ 46 | 47 | render() { 48 | var props = { 49 | x: (5 - this.props.config.size) * 10, 50 | y: this.props.index * 12, 51 | width: this.props.config.size * 10, 52 | height: 10 53 | }; 54 | 55 | var cx = React.addons.classSet; 56 | var classes = cx({ 57 | 'config': true, 58 | 'selected': this.props.selected && (this.props.count > 0), 59 | 'inactive': (this.props.count == 0), 60 | 'ship': true, 61 | 'configuration-ship': true 62 | }); 63 | 64 | return ( 65 | 66 | 67 | 68 | {"x" + this.props.count} 69 | 70 | 71 | {this.props.config.name} 72 | 73 | 74 | ); 75 | } 76 | }); 77 | 78 | module.exports = ConfigPanel; -------------------------------------------------------------------------------- /client/gameViews/SetupBoard.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons') 2 | , _ = require('lodash') 3 | , Actions = require('./../actions') 4 | , {Cell} = require('./board/Cell') 5 | , Coordinate = require('./board/Coordinate') 6 | , Ship = require('./board/Ship'); 7 | 8 | var SetupBoard = React.createClass({ 9 | 10 | handleShipClick(ship, event) { 11 | event.stopPropagation(); 12 | Actions.setup.selectShip(ship); 13 | }, 14 | 15 | pivotShip(ship, event) { 16 | event.stopPropagation(); 17 | Actions.setup.selectShip(ship); 18 | Actions.setup.pivotShip(); 19 | }, 20 | 21 | handleCellClick(cellProps) { 22 | var cell = {x: cellProps.x, y: cellProps.y}; 23 | Actions.setup.selectCell(cell, this.props.setup.ships); 24 | }, 25 | 26 | render() { 27 | var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', coords = []; 28 | var selectedId, {setup} = this.props; 29 | if (setup.selected) { 30 | if (setup.selected.type == 'board') { 31 | selectedId = setup.selected.item.id; 32 | } 33 | } 34 | 35 | for(var x = 0; x < setup.config.boardSize; x++) { 36 | var y = -1; 37 | coords.push(); 38 | }; 39 | for(var y = 0; y < setup.config.boardSize; y++) { 40 | var x = -1; 41 | coords.push(); 42 | }; 43 | 44 | var cells = []; 45 | for(var x = 0; x < setup.config.boardSize; x++) { 46 | for(var y = 0; y < setup.config.boardSize; y++) { 47 | var cellProps = { 48 | key: x + ' ' + y, 49 | x: x, 50 | y: y 51 | }; 52 | cells.push(); 53 | }; 54 | }; 55 | 56 | var ships = setup.ships.map((ship) => { 57 | var shipProps = { 58 | key: ship.id, 59 | ship: ship, 60 | selected: (selectedId == ship.id), 61 | onShipClick: this.handleShipClick.bind(this, ship), 62 | onShipDoubleClick: this.pivotShip.bind(this, ship) 63 | } 64 | return (); 65 | }); 66 | 67 | var viewBox = [-10, -10, (setup.config.boardSize + 1) * 10, (setup.config.boardSize + 1) * 10]; 68 | 69 | return ( 70 |
71 | 72 | {coords} 73 | {cells} 74 | {ships} 75 | 76 |
77 | ); 78 | } 79 | }); 80 | 81 | module.exports = SetupBoard; -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var browserify = require('browserify'); 3 | var source = require('vinyl-source-stream'); 4 | 5 | var paths = { 6 | src: { 7 | client: { 8 | scripts: './client/**/*', 9 | fonts: './node_modules/bootstrap/fonts/*', 10 | app: './client/main.js' 11 | }, 12 | server: { 13 | jsx: ['components/*.jsx'], 14 | js: ['components/js/**/*.js'], 15 | app: 'app.js' 16 | } 17 | }, 18 | dest: { 19 | client: { 20 | scripts: 'public/scripts', 21 | fonts: 'public/fonts', 22 | bundle: 'bundle.js' 23 | }, 24 | bundlesFilter: '!public/scripts/**/*.js' 25 | } 26 | }; 27 | 28 | var run = require('gulp-run'); 29 | gulp.task('init', function () { 30 | 31 | gulp.src(paths.src.client.fonts) 32 | .pipe(gulp.dest(paths.dest.client.fonts)); 33 | 34 | run('node ./node_modules/jquery-builder/bin/builder.js') 35 | .pipe(gulp.dest('public/scripts/jquery.js')); 36 | }); 37 | 38 | var argv = require('yargs').argv; 39 | var uglify = require('gulp-uglify'); 40 | var gulpif = require('gulp-if'); 41 | var buffer = require('vinyl-buffer'); 42 | var production = !!(argv.production); // true if --production flag is used 43 | 44 | gulp.task('browserify', function () { 45 | browserify({extensions: ['.jsx', '.js']}) 46 | .transform('reactify', {"es6": true}) //don't generate intermediate js files 47 | .require(paths.src.client.app, { entry: true }) 48 | .require('react') //chrome tools for React 49 | .bundle() 50 | .on('error', function(err){ 51 | console.log(err.message); 52 | }) 53 | .pipe(source(paths.dest.client.bundle)) 54 | .pipe(buffer()) 55 | .pipe(gulpif(production, uglify())) 56 | .pipe(gulp.dest(paths.dest.client.scripts)); 57 | }); 58 | 59 | var less = require('gulp-less'); 60 | var path = require('path'); 61 | 62 | gulp.task('less', function () { 63 | gulp.src('./public/stylesheets/*.less') 64 | .pipe(less()) 65 | .pipe(gulp.dest('./public/stylesheets')); 66 | }); 67 | 68 | gulp.task('watch-client', function () { 69 | gulp.watch([paths.src.client.scripts], ['browserify']) 70 | }); 71 | 72 | var nodemon = require('gulp-nodemon'); 73 | gulp.task('watch-server', function () { 74 | nodemon({ 75 | script: 'app.js', 76 | ext: 'js jsx json', 77 | ignore: ['client/*', 'public/*', 'gulpfile.js'], 78 | watch: ['app.js', 'game', 'views', 'routes'] 79 | }) 80 | .on('restart', function () { 81 | console.log('watch-server restarted!'); 82 | }); 83 | } 84 | ); 85 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(itemscope itemtype="http://schema.org/Product") 3 | head 4 | title= title 5 | meta(name="keywords" content="battleship,online,html5,browser,board,game,javascript,reactJS,css,web,sockets") 6 | meta(name="author" content="Janusz Kacalak") 7 | meta(name="description" content="Play the classic Battleship board game online in your browser!") 8 | meta(property="og:title" content="Battleships - online game") 9 | meta(property="og:site_name" content="Battleships") 10 | meta(property="fb:app_id" content="633605170101399") 11 | meta(property="og:image" content="http://battleships.mobi/images/logo.png") 12 | meta(property="og:image:type" content="image/png") 13 | meta(property="og:image:width" content="1024") 14 | meta(property="og:image:height" content="1024") 15 | meta(property="og:url" content="http://battleships.mobi") 16 | meta(property="og:description" content="Play the classic Battleship board game online in your browser!") 17 | 18 | meta(itemprop="name" content="Battleships - online game") 19 | meta(itemprop="description" content="Play the classic board game Battleship online in your browser!") 20 | meta(itemprop="image" content="http://battleships.mobi/images/logo.png") 21 | 22 | meta(name="twitter:card" content="summary") 23 | meta(name="twitter:url" content="http://battleships.mobi") 24 | meta(name="twitter:title" content="Battleships - online game") 25 | meta(name="twitter:description" content="Play the classic board game Battleship online in your browser!") 26 | meta(name="twitter:image" content="http://battleships.mobi/images/logo.png") 27 | 28 | link(rel='stylesheet', href='/stylesheets/style.css') 29 | script(src='/socket.io/socket.io.js') 30 | meta(name="viewport" content="initial-scale=1, maximum-scale=1") 31 | 32 | link(rel="apple-touch-icon" sizes="57x57" href="/apple-touch-icon-57x57.png") 33 | link(rel="apple-touch-icon" sizes="60x60" href="/apple-touch-icon-60x60.png") 34 | link(rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-72x72.png") 35 | link(rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon-76x76.png") 36 | link(rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32") 37 | link(rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96") 38 | link(rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16") 39 | link(rel="manifest" href="/manifest.json") 40 | link(name="msapplication-TileColor" content="#da532c") 41 | link(name="theme-color" content="#ffffff") 42 | body 43 | block content 44 | block footer 45 | -------------------------------------------------------------------------------- /client/gameViews/PlayBoard.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons') 2 | , Reflux = require('reflux') 3 | , _ = require('lodash') 4 | , Actions = require('./../actions') 5 | , {Cell, Hit} = require('./board/Cell') 6 | , Ship = require('./board/Ship') 7 | , GameShip = require('./board/GameShip') 8 | , GamePhaseStore = require('./../stores/GamePhaseStore') 9 | , phase = require('./../gamePhase'); 10 | 11 | var PlayBoard = React.createClass({ 12 | mixins: [Reflux.ListenerMixin], 13 | 14 | getInitialState() { 15 | return {active: false}; 16 | }, 17 | 18 | componentDidMount() { 19 | this.listenTo(GamePhaseStore, this.handleGameEvents); 20 | }, 21 | 22 | handleGameEvents(game) { 23 | if (game.phase == phase.gameMyTurn) { 24 | this.setState({active: true}); 25 | } 26 | if (game.phase == phase.gameOpponentsTurn || game.phase == phase.readyToShoot) { 27 | this.setState({active: false}); 28 | } 29 | }, 30 | 31 | handleCellClick(cellProps) { 32 | var cell = {x: cellProps.x, y: cellProps.y}; 33 | Actions.game.shoot(cell); 34 | }, 35 | 36 | render() { 37 | var cells = [], ships = [], hits = [], {props} = this, {board} = props; 38 | if (board) { 39 | for (var x = 0; x < props.xsize; x++) { 40 | for (var y = 0; y < props.ysize; y++) { 41 | var shotAtCell = _.find(board.shots, (shot) => { return (shot.position.x == x && shot.position.y == y); }); 42 | var canShoot = !props.previewBoard && !shotAtCell; 43 | var cellProps = { 44 | key: x + ' ' + y, 45 | x: x, 46 | y: y, 47 | shot: shotAtCell, 48 | onCellClick: canShoot ? this.handleCellClick.bind(this, {x: x, y: y}) : null, 49 | update: (board.update == shotAtCell) 50 | }; 51 | cells.push(); 52 | } 53 | } 54 | 55 | _.chain(board.shots) 56 | .filter({isHit: true}) 57 | .forEach((shot) => { 58 | hits.push(); 59 | }); 60 | 61 | board.ships.forEach((ship, index) => { 62 | ships.push() 63 | }); 64 | } 65 | 66 | return ( 67 |
68 |

69 | {this.props.previewBoard ? "My ships" : "Enemy ships"} 70 | {(!this.props.previewBoard && this.state.active) ? 71 | - SHOOT! : null 72 | } 73 |

74 |
75 | 76 | {cells} 77 | {hits} 78 | {ships} 79 | 80 |
81 |
82 | ); 83 | } 84 | }); 85 | 86 | module.exports = PlayBoard; -------------------------------------------------------------------------------- /game/Lobby.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | , messageHelper = require('./messageHelper') 3 | , validator = require('./Validator'); 4 | 5 | var Lobby = function () { 6 | var users = []; 7 | var invitations = []; 8 | 9 | function LobbyUser(id) { 10 | this.id = id; 11 | this.isPlaying = false; 12 | } 13 | 14 | function getLobbyState() { 15 | return {users: users, invitations: invitations}; 16 | }; 17 | 18 | function findUser(id) { 19 | return _.find(users, {id: id}); 20 | } 21 | 22 | this.getLobbyState = function () { 23 | return getLobbyState(); 24 | }; 25 | 26 | this.enterLobby = function (username, wasPlaying) { 27 | if (!username) { 28 | return messageHelper.toResult(new Error('User name is empty.')); 29 | } 30 | 31 | var validationError = validator.validateUserId(username); 32 | username = username.trim(); 33 | if(validationError) { 34 | return messageHelper.toResult(new Error(validationError)); 35 | } 36 | 37 | var storedUser = _.find(users, {id: username}); 38 | if (!wasPlaying) { 39 | if (storedUser) { // username already exists 40 | return messageHelper.toResult(new Error('The username already exists.')); 41 | } 42 | 43 | // join lobby 44 | var user = new LobbyUser(username); 45 | users.push(user); 46 | return messageHelper.toResult({user: user}); 47 | } 48 | else { 49 | storedUser.isPlaying = false; 50 | return messageHelper.toResult({user: storedUser}); 51 | } 52 | }; 53 | 54 | this.leaveLobby = function (username, startsPlay) { 55 | if (!username) { 56 | return messageHelper.toResult(new Error('User name is empty.')); 57 | } 58 | 59 | var storedUser = findUser(username); 60 | if (!storedUser) { 61 | return messageHelper.toResult(new Error('User with the given ID doesn\'t exist.')); 62 | } 63 | 64 | // join lobby 65 | var user = {id: username}; 66 | if (!startsPlay) { 67 | _.remove(users, user); 68 | _.remove(invitations, {from: username}); 69 | _.remove(invitations, {to: username}); 70 | } 71 | else { 72 | storedUser.isPlaying = true; 73 | } 74 | return messageHelper.OK(); 75 | }; 76 | 77 | this.inviteUser = function (userId, invitingUserId) { 78 | var invitingUser = _.find(users, {id: invitingUserId}); 79 | if (!invitingUser) { // user isn't in lobby 80 | return messageHelper.toResult(new Error('You\'re not in the lobby.')); 81 | } 82 | 83 | if (userId === invitingUserId) { // self invitation 84 | return messageHelper.toResult(new Error('You can\'t invite yourself!')); 85 | } 86 | 87 | var user = _.find(users, {id: userId}); 88 | if (!user) { 89 | return messageHelper.toResult(new Error('User not found in lobby!')); 90 | } 91 | 92 | var invitation = {from: invitingUserId, to: userId}; 93 | if (_.find(invitations, invitation)) { 94 | return messageHelper.toResult(new Error('The user has already an invitation from you.')); 95 | } 96 | 97 | invitations.push(invitation); 98 | return messageHelper.toResult({invitation: invitation}); 99 | } 100 | 101 | this.acceptInvitation = function (accepted, invitation) { 102 | if (accepted) { 103 | if (!_.find(invitations, invitation)) { 104 | return messageHelper.toResult(new Error('You\'re not invited by this user!')); 105 | } 106 | 107 | _.find(users, {id: invitation.from}).isPlaying = true; 108 | _.find(users, {id: invitation.to}).isPlaying = true; 109 | _.remove(invitations, invitation); 110 | } 111 | return messageHelper.toResult({accepted: accepted, invitation: invitation}); 112 | } 113 | }; 114 | 115 | module.exports = Lobby; -------------------------------------------------------------------------------- /client/gameViews/Lobby.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons') 2 | , Reflux = require('reflux') 3 | , _ = require('lodash') 4 | , Actions = require('./../actions') 5 | , LobbyStore = require('./../stores/LobbyStore'); 6 | 7 | var Lobby = React.createClass({ 8 | mixins: [Reflux.ListenerMixin], 9 | 10 | getInitialState() { 11 | return { 12 | userId: null, 13 | users: [] 14 | } 15 | }, 16 | 17 | componentDidMount() { 18 | this.listenTo(LobbyStore, this.updateLobby); 19 | 20 | this.updateLobby(LobbyStore.state); 21 | }, 22 | 23 | updateLobby(update) { 24 | this.setState(update); 25 | }, 26 | 27 | handleInvitationClick(user) { 28 | var {state} = this; 29 | if (user.hasInvited) { 30 | Actions.init.acceptInvitation(true, state.userId, user.id); 31 | } 32 | else { 33 | Actions.init.inviteUser(user.id); 34 | } 35 | }, 36 | 37 | onSinglePlay() { 38 | Actions.init.playSingle(this.state.userId); 39 | }, 40 | 41 | render() { 42 | var {state} = this, items = []; 43 | state.users.forEach((user) => { 44 | if (user.id != state.userId) { 45 | items.push(); 46 | } 47 | }); 48 | return ( 49 |
50 | {items.length > 0 ? 51 |
52 |
53 |

Invite other users or accept an invitation to start playing!

54 |
55 | 56 |
You can also play in 57 | 60 |
61 | 62 |
63 |
Signed-in users:
64 |
65 |
    66 | {items} 67 |
68 |
69 |
70 |
71 | : 72 |
73 |

There are currently no other users in the lobby.

74 |

Please wait or invite a friend!

75 |
You can also play in 76 | 79 |
80 |
81 | } 82 |
83 | ); 84 | } 85 | }); 86 | 87 | 88 | var UserItem = React.createClass({ 89 | render() { 90 | var {props} = this; 91 | 92 | var getCaption = () => { 93 | if (props.user.isPlaying) { 94 | return 'Is playing..'; 95 | } 96 | else { 97 | if (props.user.hasInvited) { 98 | return 'Accept invitation'; 99 | } 100 | else if (props.user.gotInvitation) { 101 | return 'Invitation sent'; 102 | } 103 | else { 104 | return 'Invite'; 105 | } 106 | } 107 | }; 108 | 109 | var itemClasses = React.addons.classSet({ 110 | 'user-item': true, 111 | 'playing': props.user.isPlaying 112 | }); 113 | 114 | var btnDisabled = props.user.gotInvitation || props.user.isPlaying; 115 | return ( 116 |
  • 117 |
    {props.user.id}
    118 |
    119 | 122 |
    123 |
  • 124 | ); 125 | } 126 | }); 127 | 128 | module.exports = Lobby; 129 | -------------------------------------------------------------------------------- /game/BoardUtils.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | var BoardUtils = { 4 | 5 | boardSize: 0, 6 | 7 | orderShipCells: function(shipCells) { 8 | if(shipCells.length < 2) return shipCells; 9 | 10 | shipCells.sort(function (a, b) { 11 | if (a.y > b.y) return 1; 12 | if (a.y < b.y) return -1; 13 | if (a.y == b.y) { 14 | if (a.x > b.x) return 1; 15 | if (a.x < b.x) return -1; 16 | if (a.x == b.x) return 0; 17 | } 18 | }); 19 | }, 20 | 21 | getTopLeftShipCell: function(shipCells) { 22 | this.orderShipCells(shipCells); 23 | return shipCells[0]; 24 | }, 25 | 26 | getBottomRightShipCell: function(shipCells) { 27 | this.orderShipCells(shipCells); 28 | return shipCells[shipCells.length - 1]; 29 | }, 30 | 31 | getAdjacentCells: function(shipCells, withoutCornerCells) { 32 | var topLeft = this.getTopLeftShipCell(shipCells); 33 | var BottomRight = this.getBottomRightShipCell(shipCells); 34 | 35 | var xMin = Math.max(0, topLeft.x - 1); 36 | var yMin = Math.max(0, topLeft.y - 1); 37 | var xMax = Math.min(this.boardSize, BottomRight.x + 1); 38 | var yMax = Math.min(this.boardSize, BottomRight.y + 1); 39 | 40 | var cornerCells = []; 41 | if (withoutCornerCells) { 42 | if(xMin < topLeft.x && yMin < topLeft.y) cornerCells.push({x: xMin, y: yMin}); 43 | if(xMin < topLeft.x && yMax > BottomRight.y) cornerCells.push({x: xMin, y: yMax}); 44 | if(xMax > BottomRight.x && yMin < topLeft.y) cornerCells.push({x: xMax, y: yMin}); 45 | if(xMax > BottomRight.x && yMax > BottomRight.y) cornerCells.push({x: xMax, y: yMax}); 46 | } 47 | 48 | var adjacentCells = []; 49 | for (var x = xMin; x <= xMax; x++) { 50 | for (var y = yMin; y <= yMax; y++) { 51 | var shipCell = (x >= topLeft.x && x <= BottomRight.x && y >= topLeft.y && y <= BottomRight.y); 52 | if (!shipCell) { 53 | var cell = {x: x, y: y}; 54 | if (withoutCornerCells) { 55 | if (!_.any(cornerCells, cell)) { 56 | adjacentCells.push(cell); 57 | } 58 | } else { 59 | adjacentCells.push({x: x, y: y}); 60 | } 61 | } 62 | } 63 | } 64 | 65 | return adjacentCells; 66 | }, 67 | 68 | canBeDropped: function(dropCells, droppedShipId, ships) { 69 | if (!this.areCellsValid(dropCells)) { 70 | return false; 71 | } 72 | var dropArea = dropCells.concat(this.getAdjacentCells(dropCells)); 73 | 74 | for (var i = 0; i < dropArea.length; i++) { 75 | var cell = dropArea[i]; 76 | if (_.find(ships, function (ship) { 77 | if (ship.id == droppedShipId) { 78 | return false; 79 | } 80 | var takenCell = _.find(ship.cells, function (shipCell) { 81 | return (shipCell.x == cell.x && shipCell.y == cell.y); 82 | }); 83 | return takenCell ? true : false; 84 | })) { 85 | return false; 86 | } 87 | } 88 | return true; 89 | }, 90 | 91 | isCellValid: function(cell) { 92 | return (cell.x < this.boardSize && cell.y < this.boardSize); 93 | }, 94 | 95 | areCellsValid: function(cells) { 96 | for (var i = 0; i < cells.length; i++) { 97 | if (!this.isCellValid(cells[i])) { 98 | return false; 99 | } 100 | } 101 | return true; 102 | }, 103 | 104 | getDropCellsForConfigItem: function(cell, ship) { 105 | var result = []; 106 | for (var i = 0; i < ship.size; i++) { 107 | result.push({x: cell.x + i, y: cell.y}); 108 | } 109 | return result; 110 | }, 111 | 112 | getDroppedCellsForShip: function(cell, originalCells) { 113 | var topLeft = this.getTopLeftShipCell(originalCells); 114 | var deltaX = cell.x - topLeft.x; 115 | var deltaY = cell.y - topLeft.y; 116 | 117 | return originalCells.map(function(cell) { 118 | return {x: cell.x + deltaX, y: cell.y + deltaY} 119 | }); 120 | } 121 | } 122 | 123 | module.exports = BoardUtils; 124 | -------------------------------------------------------------------------------- /client/gameViews/ShootingView.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | , Reflux = require('reflux') 3 | , SetStateMixin = require('../utils/SetStateMixin') 4 | , _ = require('lodash') 5 | , Actions = require('../actions') 6 | , phase = require('../gamePhase') 7 | , PlayBoard = require('./PlayBoard') 8 | , ConfigStore = require('../stores/ConfigStore') 9 | , GameStore = require('../stores/GamePhaseStore') 10 | , GameboardStore = require('../stores/GameboardStore'); 11 | 12 | var ShootingView = React.createClass({ 13 | mixins: [Reflux.ListenerMixin, SetStateMixin], 14 | 15 | componentDidMount() { 16 | this.listenTo(GameStore, this.onGamePhaseChange); 17 | this.listenTo(GameboardStore, this.onGameStateChange); 18 | this.setBoardSize(); 19 | Actions.game.initGameboard(); 20 | }, 21 | 22 | getInitialState() { 23 | return { 24 | boardSize: 0, 25 | isOpponentsBoardVisible: true, 26 | active: false, 27 | previewBoard: null, 28 | opponentsBoard: null, 29 | switched: false 30 | } 31 | }, 32 | 33 | setBoardSize() { 34 | this.setState({boardSize: ConfigStore.getState().boardSize}); 35 | }, 36 | 37 | componentWillUnmount() { 38 | console.log('ShootingPanel unmount!'); 39 | }, 40 | 41 | onGamePhaseChange(game) { 42 | var active = (game.phase == phase.gameMyTurn); 43 | this.setStateIfMounted({ 44 | active: active, 45 | isOpponentsBoardVisible: active 46 | }); 47 | }, 48 | 49 | onGameStateChange(gameboard) { 50 | this.setStateIfMounted({ 51 | previewBoard: gameboard.previewBoard, 52 | shootingBoard: gameboard.shootingBoard, 53 | switched: false 54 | }); 55 | }, 56 | 57 | handleSwitch() { 58 | this.setState({ 59 | isOpponentsBoardVisible: !this.state.isOpponentsBoardVisible, 60 | switched: true 61 | }); 62 | }, 63 | 64 | render() { 65 | var {state} = this; 66 | var switchBtn = (() => { 67 | if (state.isOpponentsBoardVisible) { 68 | return ( 69 | ); 73 | } 74 | else { 75 | return ( 76 | ); 80 | } 81 | })(); 82 | 83 | var cx = React.addons.classSet; 84 | var previewBoardClasses = cx({ 85 | 'pb': true, 86 | 'me': true, 87 | 'my-board-active': !state.isOpponentsBoardVisible, 88 | 'switched': state.switched 89 | }); 90 | 91 | var opponentsClasses = cx({ 92 | 'pb': true, 93 | 'opponent': true, 94 | 'my-board-active': !state.isOpponentsBoardVisible, 95 | 'switched': state.switched 96 | }); 97 | 98 | var overlay = !state.active ? 99 | ( 100 |
    101 | Opponent's turn 102 |
    103 | ) : null; 104 | 105 | return ( 106 |
    107 |
    108 | {switchBtn} 109 |
    110 |
    111 |
    112 | {overlay} 113 |
    114 | 115 |
    116 |
    117 | 118 |
    119 |
    120 |
    121 |
    122 | ); 123 | } 124 | }); 125 | 126 | module.exports = ShootingView; 127 | -------------------------------------------------------------------------------- /client/stores/GameEventsStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , _ = require('lodash') 4 | , gameEvents = require('../../game/gameEvents') 5 | , phase = require('../gamePhase') 6 | , Actions = require('../actions') 7 | , GamePhaseStore = require('./GamePhaseStore') 8 | , InvitationStore = require('./InvitationStore') 9 | , AppStore = require('./UserStore'); 10 | 11 | var GameEventsStore = Reflux.createStore({ 12 | init() { 13 | 14 | this.listenTo(GamePhaseStore, this.setGamePhase); 15 | this.listenTo(InvitationStore, this.handleInvitationEvent); 16 | this.listenTo(AppStore, this.setUser) 17 | 18 | socket.on('disconnect', (status) => { 19 | if(status != 'forced close') { 20 | this.handleErrorResult({error: 'You\'ve been disconnected from the server!'}); 21 | } 22 | }); 23 | 24 | socket.on(gameEvents.server.enterLobbyStatus, (result) => { 25 | if(!result.isSuccessful) { 26 | this.handleErrorResult(result); 27 | } 28 | }); 29 | 30 | socket.on(gameEvents.server.gameStarted, (result) => { 31 | result.message = 'You have joined the game!' 32 | this.handleStandardEvent(result); 33 | }); 34 | 35 | socket.on(gameEvents.server.shipsPlaced, (result) => { 36 | this.handleStandardEvent(result); 37 | }); 38 | 39 | socket.on(gameEvents.server.activatePlayer, (result) => { 40 | this.handleStandardEvent(result); 41 | }); 42 | 43 | socket.on(gameEvents.server.shotUpdate, (result) => { 44 | if(result.shipWasHit || result.shipWasDestroyed) { 45 | var message = result.me ? this.opponentId + "'s" : "Your "; 46 | message += result.shipWasDestroyed ? " ship was destroyed!" : " ship was hit!"; 47 | 48 | this.trigger({ 49 | type: result.me ? 'success' : 'info' , 50 | message 51 | }); 52 | } 53 | }); 54 | 55 | socket.on(gameEvents.server.playerLeft, (result) => { 56 | this.handleStandardEvent(result, {type: 'error'}); 57 | }); 58 | }, 59 | 60 | handleStandardEvent(result, opts ) { 61 | if(!this.handleErrorResult(result, opts)) { 62 | this.handleInfoResult(result, opts); 63 | } 64 | }, 65 | 66 | handleInfoResult(result, opts) { 67 | var eventType = (opts && opts.type) ? opts.type : 'info'; 68 | 69 | if (result.isSuccessful) { 70 | this.trigger({ 71 | type: eventType, 72 | message: result.message 73 | }); 74 | return true; 75 | } 76 | return false; 77 | }, 78 | 79 | handleErrorResult(result) { 80 | if (!result.isSuccessful) { 81 | this.trigger({ 82 | type: 'error', 83 | message: result.error 84 | }); 85 | return true; 86 | } 87 | return false; 88 | }, 89 | 90 | setGamePhase(game) { 91 | this.phase = game.phase; 92 | }, 93 | 94 | setUser(appState) { 95 | this.userId = appState.userId; 96 | this.opponentId = appState.opponentId; 97 | }, 98 | 99 | handleInvitationEvent(event) { 100 | 101 | if(event.type == 'error') { 102 | return this.trigger(event); 103 | } 104 | 105 | var feedback; 106 | switch (event.topic) { 107 | case 'invitation forward': 108 | feedback = this.getInvitationForward(event); 109 | break; 110 | case 'invitation response': 111 | feedback = this.getInvitationResponse(event); 112 | break; 113 | } 114 | if (feedback) { 115 | this.trigger(feedback); 116 | } 117 | }, 118 | 119 | getInvitationForward(response) { 120 | var {invitation} = response; 121 | if (this.userId == invitation.to) { 122 | return { 123 | type: 'info', 124 | header: 'New invitation', 125 | message: `An invitation from user ${invitation.from}.` 126 | } 127 | } 128 | return null; 129 | }, 130 | 131 | getInvitationResponse(invitation) { 132 | var {state} = this; 133 | if (this.userId == invitation.from) { 134 | return { 135 | type: 'info', 136 | header: '', 137 | message: `User ${invitation.to} has ${invitation.accepted ? 'accepted' : 'rejected'} your invitation.` 138 | } 139 | } 140 | return null; 141 | } 142 | }); 143 | 144 | module.exports = GameEventsStore; 145 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | 2 | (function initSocket() { 3 | var socket = require('./socket'); //init and cache socket instance 4 | socket.io.close(); 5 | 6 | var GamePhaseStore = require('./stores/GamePhaseStore'); 7 | 8 | GamePhaseStore.listen(function(game) { 9 | if(game.phase == phase.signIn) { 10 | //close socket when not signed-in 11 | socket.io.close(); 12 | }; 13 | }); 14 | })(); 15 | 16 | var React = require('react') 17 | , Reflux = require('reflux') 18 | , Game = require('./Game') 19 | , phase = require('./gamePhase') 20 | , Actions = require('./actions') 21 | , AppStore = require('./stores/UserStore') 22 | , utils = require('./utils/domUtils'); 23 | 24 | var ReactToastr = require('react-toastr'); 25 | var {ToastContainer} = ReactToastr; 26 | var NavPanel = require('./NavPanel'); 27 | var ToastMessageFactory = React.createFactory(ReactToastr.ToastMessage.animation); 28 | var ModalBox = require('./ModalBox'); 29 | 30 | var Body = React.createClass({ 31 | mixins: [Reflux.listenTo(AppStore, 'onAppStateChange')], 32 | 33 | getInitialState() { 34 | return { 35 | showNav: false, 36 | app: {userId: null} 37 | }; 38 | }, 39 | 40 | onAppStateChange(appState) { 41 | this.setState({ 42 | showNav: this.state.showNav && !!appState.userId, 43 | app: appState 44 | }); 45 | }, 46 | 47 | setNavPanelVisibility(e) { 48 | var {state, refs} = this; 49 | if (state.app.signedIn && e.target == refs.navBtn.getDOMNode()) { 50 | this.setState({showNav: !state.showNav}); 51 | } 52 | else { 53 | var isOutsideNav = !(e.target == refs.nav.getDOMNode()) && !utils.isElementChildOf(e.target, refs.nav.getDOMNode()); 54 | 55 | if (isOutsideNav) { 56 | this.setState({showNav: false}); 57 | } 58 | } 59 | }, 60 | 61 | render: function () { 62 | var {state} = this; 63 | return ( 64 |
    65 | 66 | 88 |
    89 |
    90 |
    91 |

    Battleships

    92 |
    93 |
    94 | 95 |
    96 |
    97 |
    98 | 99 | 100 |
    101 |
    102 | 113 | 114 |
    ); 115 | }, 116 | 117 | componentDidMount: function () { 118 | window.toastr = this.refs.container; 119 | window.modalBox = this.refs.modal; 120 | } 121 | }); 122 | 123 | document.addEventListener('DOMContentLoaded', () => { 124 | 125 | React.render(, document.getElementById('app')); 126 | 127 | Actions.init.showSignIn(); 128 | }); 129 | 130 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /client/stores/GamePhaseStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , Actions = require('../actions') 4 | , _ = require('lodash') 5 | , phase = require('../gamePhase') 6 | , gameEvents = require('../../game/gameEvents') 7 | , validator = require('../../game/Validator'); 8 | 9 | var GamePhaseStore = Reflux.createStore({ 10 | init() { 11 | 12 | this.game = {}; 13 | 14 | this.listenTo(Actions.init.showSignIn, this.initSignIn); 15 | this.listenTo(Actions.init.signIn, this.initStoresOnSignIn); 16 | this.listenTo(Actions.init.signIn, this.enterLobby); 17 | this.listenTo(Actions.init.playSingle, this.playSingle); 18 | this.listenTo(Actions.game.shoot, this.takeShot); 19 | this.listenTo(Actions.game.quit, this.quitGame); 20 | this.listenTo(Actions.init.signOut, this.signOut); 21 | 22 | socket.on('disconnect', () => { 23 | this.game = {phase: phase.signIn}; 24 | this.trigger(this.game); 25 | }); 26 | 27 | socket.on('connect_error', this.updateConnectionStatus); 28 | socket.on('connect_timeout', this.updateConnectionStatus); 29 | 30 | socket.on(gameEvents.server.gameStarted, (result) => { 31 | if (result.isSuccessful) { 32 | this.game.phase = phase.setup; 33 | this.game.opponent = result.opponent; 34 | this.trigger(this.game); 35 | } 36 | }); 37 | 38 | socket.on(gameEvents.server.shipsPlaced, (result) => { 39 | if (result.isSuccessful) { 40 | this.game.phase = phase.readyToShoot; 41 | this.trigger(this.game); 42 | } 43 | }); 44 | 45 | socket.on(gameEvents.server.activatePlayer, (result) => { 46 | if (result.isSuccessful) { 47 | this.game.phase = phase.gameMyTurn; 48 | this.game.shotUpdate = undefined; 49 | this.trigger(this.game); 50 | } 51 | }); 52 | 53 | socket.on(gameEvents.server.gameOver, (result) => { 54 | this.game.phase = phase.gameOver; 55 | this.game.hasWon = result.hasWon; 56 | this.trigger(this.game); 57 | }); 58 | 59 | socket.on(gameEvents.server.playerSwitched, (result) => { 60 | if (result.isSuccessful) { 61 | this.game.phase = phase.gameOpponentsTurn; 62 | this.game.shotUpdate = undefined; 63 | this.trigger(this.game); 64 | } 65 | }); 66 | 67 | socket.on(gameEvents.server.playerLeft, (result) => { 68 | if (result.isSuccessful) { 69 | this.game.phase = phase.inLobby; 70 | this.trigger(this.game); 71 | } 72 | }); 73 | 74 | socket.on(gameEvents.server.signOutStatus, (result) => { 75 | if (result.isSuccessful) { 76 | this.game = {phase: phase.signIn}; 77 | 78 | 79 | if(socket) { 80 | socket.io.close(); 81 | } 82 | 83 | this.trigger(this.game); 84 | } 85 | }); 86 | 87 | socket.on(gameEvents.server.quitGameStatus, (result) => { 88 | if (result.isSuccessful) { 89 | this.game.phase = phase.inLobby; 90 | this.trigger(this.game); 91 | } 92 | }); 93 | }, 94 | 95 | 96 | initStoresOnSignIn() { 97 | require('./LobbyStore'); 98 | require('./ConfigStore'); 99 | }, 100 | 101 | playSingle() { 102 | socket.emit(gameEvents.client.playSingle); 103 | }, 104 | 105 | quitGame() { 106 | socket.emit(gameEvents.client.quitGame); 107 | }, 108 | 109 | signOut() { 110 | socket.emit(gameEvents.client.signOut); 111 | }, 112 | 113 | updateConnectionStatus() { 114 | this.game = {phase: phase.signIn}; 115 | this.trigger(this.game); 116 | }, 117 | 118 | takeShot(cell) { 119 | socket.emit(gameEvents.client.shoot, cell); 120 | }, 121 | 122 | initSignIn() { 123 | this.game = {phase: phase.signIn}; 124 | this.trigger(this.game); 125 | }, 126 | 127 | enterLobby(userName) { 128 | var validationError = validator.validateUserId(userName); 129 | if(validationError) { 130 | return Actions.common.error(validationError); 131 | } 132 | 133 | socket.connect(); 134 | 135 | socket.on(gameEvents.server.enterLobbyStatus, (result) => { 136 | if(result.isSuccessful) { 137 | this.game.phase = phase.inLobby; 138 | this.game.user = result.user; 139 | this.trigger(this.game); 140 | } 141 | }); 142 | socket.emit(gameEvents.client.enterLobby, userName); 143 | } 144 | }); 145 | 146 | module.exports = GamePhaseStore; 147 | -------------------------------------------------------------------------------- /test/spec/OpponentSpec.js: -------------------------------------------------------------------------------- 1 | var opponent = new (require('../../game/battleships/Opponent'))(); 2 | var _ = require('lodash'); 3 | 4 | describe('BoardUtils', function () { 5 | var boardUtils = require('../../game/BoardUtils'); 6 | it('finds adjacent positions', function() { 7 | boardUtils.boardSize = 10; 8 | 9 | var adjacent = boardUtils.getAdjacentCells([{x:5, y:0}], true); 10 | 11 | expect(adjacent).toContain({x:4, y:0}); 12 | expect(adjacent).toContain({x:6, y:0}); 13 | expect(adjacent).toContain({x:5, y:1}); 14 | }); 15 | }); 16 | 17 | describe('Opponent', function () { 18 | 19 | var cfg = {boardSize: 10}; 20 | opponent.init(cfg); 21 | 22 | it('shoots at a position', function () { 23 | var board = { 24 | inactive: [], 25 | hitCells: [], 26 | smallestShipLeft: 4 27 | }; 28 | var shot = opponent.shoot(board); 29 | 30 | expect(shot).toBeTruthy(); 31 | expect(shot.x).toBeGreaterThan(-1); 32 | expect(shot.x).toBeLessThan(cfg.boardSize); 33 | expect(shot.y).toBeGreaterThan(-1); 34 | expect(shot.y).toBeLessThan(cfg.boardSize); 35 | }); 36 | 37 | it('throws an Error when the boardState is not given', function () { 38 | expect(function () { 39 | opponent.shoot(null) 40 | }).toThrow("boardState is Empty"); 41 | }); 42 | 43 | it('shoots at different positions', function () { 44 | var board = { 45 | inactive: [], 46 | hitCells: [{x: 1, y: 1}], 47 | smallestShipLeft: 4 48 | }; 49 | board.inactive.push(opponent.shoot(board)); 50 | board.inactive.push(opponent.shoot(board)); 51 | board.inactive.push(opponent.shoot(board)); 52 | 53 | expect(board.inactive[0]).not.toEqual(board.inactive[1]); 54 | expect(board.inactive[0]).not.toEqual(board.inactive[2]); 55 | expect(board.inactive[1]).not.toEqual(board.inactive[2]); 56 | }); 57 | 58 | it('shoots on adjacent cell of a hit ship', function () { 59 | var board = { 60 | inactive: [{x: 5, y: 5}, {x: 5, y: 7}, {x: 4, y: 6}], 61 | hitCells: [{x: 5, y: 6}], 62 | smallestShipLeft: 4 63 | }; 64 | expect([{x: 6, y: 6}]).toContain(opponent.shoot(board)); 65 | }); 66 | 67 | it('shoots along the hit ship', function () { 68 | var board = { 69 | inactive: [{x: 5, y: 5}, {x: 1, y: 0}, {x: 1, y: 1}], 70 | hitCells: [{x: 1, y: 0}, {x: 1, y: 1}], 71 | smallestShipLeft: 1 72 | }; 73 | expect(opponent.shoot(board)).toEqual({x: 1, y: 2}); 74 | }); 75 | 76 | it('finds a position where a ship can be hit', function () { 77 | opponent.init({boardSize: 5}); 78 | var board = { 79 | inactive: [ 80 | {x: 1, y: 0}, {x: 1, y: 1}, {x: 1, y: 2}, {x: 1, y: 3}, 81 | {x: 0, y: 1}, {x: 1, y: 1}, {x: 2, y: 1}, {x: 3, y: 1}, {x: 4, y: 1} 82 | ], 83 | hitCells: [{x: 4, y: 4}], 84 | smallestShipLeft: 4 85 | }; 86 | 87 | var pos = opponent.shoot(board); 88 | expect([{x: 0, y: 4}, {x: 1, y: 4}, {x: 2, y: 4}, {x: 3, y: 4}, {x: 4, y: 4}]).toContain(pos); 89 | console.log(pos); 90 | }); 91 | 92 | it('places ships', function () { 93 | var config = { 94 | boardSize: 10, 95 | ships: [ 96 | {name: 'Battleship', size: 4, count: 1}, 97 | {name: 'Submarine', size: 3, count: 2}, 98 | {name: 'Cruiser', size: 2, count: 2}, 99 | {name: 'Destroyer', size: 1, count: 2} 100 | ] 101 | }; 102 | 103 | var placement = opponent.placeShips(config); 104 | expect(placement).toBeTruthy(); 105 | expect(placement.length).toEqual(7); 106 | 107 | //console.log(placement) 108 | }); 109 | 110 | it('updates game state', function () { 111 | opponent.init({ 112 | boardSize: 10, 113 | ships: [ 114 | {name: 'Battleship', size: 4, count: 1}, 115 | {name: 'Submarine', size: 3, count: 2}, 116 | {name: 'Cruiser', size: 2, count: 2}, 117 | {name: 'Destroyer', size: 1, count: 2} 118 | ] 119 | }); 120 | var board = { 121 | inactive: [], 122 | hitCells: [], 123 | smallestShipLeft: 4 124 | }; 125 | 126 | var shot = { 127 | shipWasHit: true, 128 | shipWasDestroyed: false, 129 | position: {x: 0, y: 0} 130 | }; 131 | board = opponent.updateGameState(board, shot); 132 | expect(board.inactive).toContain({x: 0, y: 0}); 133 | expect(board.hitCells).toContain({x: 0, y: 0}); 134 | 135 | var shot = { 136 | shipWasHit: true, 137 | shipWasDestroyed: true, 138 | ship: {positions: [{x: 0, y: 0}, {x: 0, y: 1}]}, 139 | position: {x: 0, y: 1} 140 | }; 141 | board = opponent.updateGameState(board, shot); 142 | expect(board.inactive.length).toEqual(6); 143 | expect(board.hitCells.length).toEqual(0); 144 | }); 145 | 146 | }); 147 | 148 | -------------------------------------------------------------------------------- /client/stores/GameboardStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , Actions = require('../actions') 4 | , _ = require('lodash') 5 | , GamePhaseStore = require('./GamePhaseStore') 6 | , SetupStore = require('./SetupStore') 7 | , BoardUtils = require('../../game/BoardUtils') 8 | , phase = require('../gamePhase') 9 | , gameEvents = require('../../game/gameEvents'); 10 | 11 | var model = { 12 | Gameboard: function(props) { 13 | props = props || {}; 14 | this.ships = props.ships || []; 15 | this.shots = props.shots || []; 16 | }, 17 | 18 | Ship: function(props) { 19 | props = props || {}; 20 | this.id = props.id || null; 21 | this.cells = props.cells || []; 22 | }, 23 | 24 | ShipCell: function(props) { 25 | props = props || {}; 26 | this.x = props.x; 27 | this.y = props.y; 28 | this.isHit = props.isHit; 29 | }, 30 | 31 | Shot: function(props) { 32 | props = props || {}; 33 | this.position = props.position; 34 | this.isHit = props.isHit; 35 | this.isAdjacentToShip = props.isAdjacentToShip; 36 | }, 37 | 38 | PreviewShot: function(props) { 39 | props = props || {}; 40 | this.position = props.position; 41 | this.isHit = props.isHit; 42 | }, 43 | 44 | StoreData: function(props) { 45 | props = props || {}; 46 | this.isGameStarted = props.isGameStarted; 47 | this.isMyTurn = props.isMyTurn; 48 | this.previewBoard = props.previewBoard; 49 | this.shootingBoard = props.shootingBoard; 50 | } 51 | }; 52 | 53 | var GameboardStore = Reflux.createStore({ 54 | init() { 55 | 56 | this.listenTo(Actions.game.initGameboard, this.triggerStateChange); 57 | this.listenTo(GamePhaseStore, this.initAfterSetup); 58 | this.listenTo(GamePhaseStore, this.setGamePhase); 59 | socket.on(gameEvents.server.shotUpdate, this.onShotReceived); 60 | }, 61 | 62 | initAfterSetup(game) { 63 | if(game.phase == phase.readyToShoot) { 64 | this.state = new model.StoreData({ 65 | isGameStarted: false, 66 | isMyTurn: false, 67 | previewBoard: new model.Gameboard({ships: SetupStore.state.ships}), 68 | shootingBoard: new model.Gameboard() 69 | }) 70 | } 71 | }, 72 | 73 | onShotReceived(result) { 74 | var {state} = this; 75 | if (result.isSuccessful && state.isGameStarted) { 76 | 77 | var shot = new model.Shot({ 78 | position: result.position, 79 | isHit: result.shipWasHit, 80 | isDestroyed: result.shipWasDestroyed, 81 | isAdjacentToShip: false 82 | }); 83 | 84 | var board; 85 | if(state.isMyTurn) { 86 | board = state.shootingBoard; 87 | board.shots.push(shot); 88 | if(result.shipWasDestroyed) { 89 | board.ships.push(new model.Ship({ 90 | id: result.ship.id, 91 | cells: result.ship.positions.map((position) => { 92 | return new model.ShipCell({x: position.x, y: position.y}) 93 | }) 94 | })); 95 | 96 | var adjacentCells = BoardUtils.getAdjacentCells(result.ship.positions); 97 | var adjacentShots = _.chain(adjacentCells) 98 | .filter((adjacent) => { 99 | return _.any(board.shots, (takenShot) => { 100 | return !(takenShot.position.x == adjacent.x && takenShot.position.y == adjacent.y); 101 | }) 102 | }) 103 | .map((adj) => { return new model.Shot({ 104 | position: {x: adj.x, y: adj.y}, 105 | isHit: false, 106 | isDestroyed: false, 107 | isAdjacentToShip: true 108 | })}) 109 | .value(); 110 | board.shots = board.shots.concat(adjacentShots); 111 | } 112 | } 113 | else { 114 | board = state.previewBoard; 115 | 116 | board.shots.push(shot); 117 | if(shot.isHit) { 118 | var myShip = _.find(board.ships, (ship) => { return (ship.id == result.ship.id); }); 119 | var updateCell = _.find(myShip.cells, (cell) => { 120 | return (cell.x == result.position.x && cell.y == result.position.y); 121 | }); 122 | updateCell.isHit = true; 123 | board.update = myShip; 124 | } 125 | else { 126 | board.update = shot; 127 | } 128 | } 129 | this.triggerStateChange(); 130 | } 131 | }, 132 | 133 | triggerStateChange() { 134 | var {state} = this; 135 | this.trigger(new model.StoreData({ 136 | isGameStarted: state.isGameStarted, 137 | isMyTurn: state.isMyTurn, 138 | previewBoard: { 139 | shots: _.filter(state.previewBoard.shots, (shot) => {return !shot.isHit}), 140 | ships: state.previewBoard.ships, 141 | update: state.previewBoard.update 142 | }, 143 | shootingBoard: state.shootingBoard 144 | })) 145 | }, 146 | 147 | setGamePhase(game) { 148 | if(game.phase == phase.gameOpponentsTurn || game.phase == phase.gameMyTurn || game.phase == phase.readyToShoot) { 149 | this.state.isGameStarted = true; 150 | 151 | if(game.phase == phase.gameMyTurn) { 152 | this.state.isMyTurn = true; 153 | } 154 | else { 155 | this.state.isMyTurn = false; 156 | } 157 | } 158 | } 159 | }); 160 | 161 | module.exports = GameboardStore; 162 | -------------------------------------------------------------------------------- /public/stylesheets/toast.min.css: -------------------------------------------------------------------------------- 1 | .toast-title{font-weight:700}.toast-message{-ms-word-wrap:break-word;word-wrap:break-word}.toast-message a,.toast-message label{color:#fff}.toast-message a:hover{color:#ccc;text-decoration:none}.toast-close-button{position:relative;right:-.3em;top:-.3em;float:right;font-size:20px;font-weight:700;color:#fff;-webkit-text-shadow:0 1px 0 #fff;text-shadow:0 1px 0 #fff;opacity:.8;-ms-filter:alpha(Opacity=80);filter:alpha(opacity=80)}.toast-close-button:focus,.toast-close-button:hover{color:#000;text-decoration:none;cursor:pointer;opacity:.4;-ms-filter:alpha(Opacity=40);filter:alpha(opacity=40)}button.toast-close-button{padding:0;cursor:pointer;background:0 0;border:0;-webkit-appearance:none}.toast-top-center{top:0;right:0;width:100%}.toast-bottom-center{bottom:0;right:0;width:100%}.toast-top-full-width{top:0;right:0;width:100%}.toast-bottom-full-width{bottom:0;right:0;width:100%}.toast-top-left{top:12px;left:12px}.toast-top-right{top:12px;right:12px}.toast-bottom-right{right:12px;bottom:12px}.toast-bottom-left{bottom:12px;left:12px}#toast-container{position:fixed;z-index:999999}#toast-container *{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box}#toast-container>div{position:relative;overflow:hidden;margin:0 0 6px;padding:15px 15px 15px 50px;width:300px;-moz-border-radius:3px;-webkit-border-radius:3px;border-radius:3px;background-position:15px center;background-repeat:no-repeat;-moz-box-shadow:0 0 12px #999;-webkit-box-shadow:0 0 12px #999;box-shadow:0 0 12px #999;color:#fff;opacity:.8;-ms-filter:alpha(Opacity=80);filter:alpha(opacity=80)}#toast-container>:hover{-moz-box-shadow:0 0 12px #000;-webkit-box-shadow:0 0 12px #000;box-shadow:0 0 12px #000;opacity:1;-ms-filter:alpha(Opacity=100);filter:alpha(opacity=100);cursor:pointer}#toast-container>.toast-info{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=)!important}#toast-container>.toast-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=)!important}#toast-container>.toast-success{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==)!important}#toast-container>.toast-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=)!important}#toast-container.toast-bottom-center>div,#toast-container.toast-top-center>div{width:300px;margin:auto}#toast-container.toast-bottom-full-width>div,#toast-container.toast-top-full-width>div{width:96%;margin:auto}.toast{background-color:#030303}.toast-success{background-color:#51a351}.toast-error{background-color:#bd362f}.toast-info{background-color:#2f96b4}.toast-warning{background-color:#f89406}.toast-progress{position:absolute;left:0;bottom:0;height:4px;background-color:#000;opacity:.4;-ms-filter:alpha(Opacity=40);filter:alpha(opacity=40)}@media all and (max-width:240px){#toast-container>div{padding:8px 8px 8px 50px;width:11em}#toast-container .toast-close-button{right:-.2em;top:-.2em}}@media all and (min-width:241px) and (max-width:480px){#toast-container>div{padding:8px 8px 8px 50px;width:18em}#toast-container .toast-close-button{right:-.2em;top:-.2em}}@media all and (min-width:481px) and (max-width:768px){#toast-container>div{padding:15px 15px 15px 50px;width:25em}} -------------------------------------------------------------------------------- /client/stores/SetupStore.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux') 2 | , socket = require('../socket') 3 | , Actions = require('../actions') 4 | , _ = require('lodash') 5 | , BoardUtils = require('../../game/BoardUtils') 6 | , gameEvents = require('../../game/gameEvents') 7 | , ConfigStore = require('./ConfigStore') 8 | , phase = require('../gamePhase') 9 | , GamePhaseStore = require('./GamePhaseStore'); 10 | 11 | function getNamedShip(ship) { 12 | return {id: ship.id ? ship.id : _.uniqueId(), cells: ship.cells}; 13 | } 14 | 15 | var SetupStore = Reflux.createStore({ 16 | 17 | reset() { 18 | this.state = { 19 | ships: [], 20 | selected: null, 21 | config: null, 22 | allPlaced: false 23 | }; 24 | }, 25 | 26 | getState() { 27 | return this.state; 28 | }, 29 | 30 | init() { 31 | this.reset(); 32 | 33 | this.utils = BoardUtils; 34 | this.listenTo(GamePhaseStore, this.checkGamePhase); 35 | this.listenTo(ConfigStore, this.setConfig); 36 | this.listenTo(Actions.setup.placeShips, this.emitShips); 37 | this.listenTo(Actions.setup.selectConfigItem, this.selectConfigItem); 38 | this.listenTo(Actions.setup.selectShip, this.selectShip); 39 | this.listenTo(Actions.setup.selectCell, this.tryDrop); 40 | this.listenTo(Actions.setup.pivotShip, this.tryPivot); 41 | }, 42 | 43 | checkGamePhase(game) { 44 | if(game.phase <= phase.inLobby) { 45 | this.reset(); 46 | } 47 | }, 48 | 49 | setConfig(config) { 50 | this.utils.boardSize = config.boardSize; 51 | this.state.config = config; 52 | this.trigger(this.state); 53 | }, 54 | 55 | selectConfigItem(ship) { 56 | this.state.selected = { 57 | type: 'config', 58 | item: ship 59 | }; 60 | this.trigger(this.state); 61 | }, 62 | 63 | selectShip(ship) { 64 | var current = this.state.selected ? this.state.selected.item : null; 65 | 66 | if (current != ship) { 67 | this.state.selected = { 68 | type: 'board', 69 | item: {id: ship.id} 70 | }; 71 | this.trigger(this.state); 72 | } 73 | }, 74 | 75 | tryPivot() { 76 | var {state} = this, {selected} = state; 77 | var ship = _.find(state.ships, {id: selected.item.id}); 78 | 79 | if (ship.cells.length > 1) { 80 | 81 | var isHorizontal = ship.cells[0].y == ship.cells[1].y; 82 | 83 | var topLeft = this.utils.getTopLeftShipCell(ship.cells); 84 | var pivoted = [topLeft]; 85 | for (var i = 0; i < ship.cells.length - 1; i++) { 86 | var cell; 87 | if (isHorizontal) { 88 | cell = {x: topLeft.x, y: topLeft.y + i + 1}; 89 | } 90 | else { 91 | cell = {x: topLeft.x + i + 1, y: topLeft.y}; 92 | } 93 | 94 | if (this.utils.isCellValid(cell)) { 95 | pivoted.push(cell); 96 | } 97 | else { 98 | pivoted = null; 99 | return; 100 | } 101 | } 102 | 103 | if (pivoted && this.utils.canBeDropped(pivoted, ship.id, state.ships)) { 104 | this.dropShip(pivoted, ship.id); 105 | state.selected = null; 106 | this.trigger(state); 107 | } 108 | } 109 | }, 110 | 111 | tryDrop(cell) { 112 | var {state} = this, ships = state.ships; 113 | var selected = state.selected; 114 | if (selected) { 115 | if (selected.type == 'config') { 116 | var cells = this.utils.getDropCellsForConfigItem(cell, selected.item); 117 | 118 | if (this.utils.canBeDropped(cells, null, ships)) { 119 | state.selected = { 120 | type: selected.type, 121 | item: cells 122 | }; 123 | this.dropShip(state.selected); 124 | state.selected = null; 125 | 126 | var configShip = _.find(state.config.ships, (item) => { 127 | return (item.size == selected.item.size); 128 | }); 129 | configShip.count--; 130 | } 131 | } 132 | else if (selected.type == 'board') { 133 | var ship = _.find(state.ships, {id: selected.item.id}) 134 | var cells = this.utils.getDroppedCellsForShip(cell, ship.cells); 135 | if (this.utils.canBeDropped(cells, selected.item.id, ships)) { 136 | this.dropShip(cells, ship.id); 137 | state.selected = null; 138 | } 139 | } 140 | state.allPlaced = (!_.any(this.state.config.ships, (item) => {return (item.count > 0);})); 141 | this.trigger(state); 142 | } 143 | }, 144 | 145 | emitShips() { 146 | var {state} = this; 147 | var allPlaced = () => { 148 | return (!_.any(state.config.ships, (item) => { 149 | return (item.count > 0); 150 | })) 151 | }; 152 | 153 | if (allPlaced()) { 154 | var toSend = state.ships.map((ship) => { 155 | return ship; 156 | }); 157 | socket.emit(gameEvents.client.placeShips, toSend); 158 | } 159 | }, 160 | 161 | dropShip(selected, id) { 162 | var {state} = this; 163 | var dropped; 164 | switch(selected.type){ 165 | case 'config': 166 | dropped = getNamedShip({cells: selected.item}); 167 | state.ships.push(dropped); 168 | break; 169 | case 'board': 170 | default: 171 | _.remove(state.ships, {id: id}); 172 | dropped = {id: id, cells: selected}; 173 | state.ships.push(dropped); 174 | break; 175 | } 176 | } 177 | }); 178 | 179 | module.exports = SetupStore; 180 | -------------------------------------------------------------------------------- /test/spec/SocketServerLobbySpec.js: -------------------------------------------------------------------------------- 1 | var io = require('socket.io-client'); 2 | var gameEvents = require('../../game/gameEvents'); 3 | var utils = require('./serverTestUtils'); 4 | var _ = require('lodash'); 5 | 6 | var http; 7 | 8 | var getServer = function (http) { 9 | return require('../../game/socket-server')(http); 10 | }; 11 | 12 | describe('Lobby service', function () { 13 | var server, invitedClient; 14 | 15 | beforeEach(function () { 16 | if (!this.server) { 17 | http = require('http').Server().listen(3000); 18 | this.server = utils.getServer(http); 19 | } 20 | if (invitedClient) { 21 | invitedClient.disconnect(); 22 | } 23 | invitedClient = utils.getClient(); 24 | }); 25 | 26 | afterEach(function () { 27 | if (http) { 28 | http.close(); 29 | } 30 | }); 31 | 32 | jasmine.getEnv().defaultTimeoutInterval = 5000; 33 | it('allows to enter lobby', function (done) { 34 | 35 | expect(invitedClient).toBeTruthy(); 36 | 37 | invitedClient.on(gameEvents.server.lobbyUpdate, function (users) { 38 | expect(users).not.toBeNull(); 39 | done(); 40 | }); 41 | invitedClient.emit(gameEvents.client.enterLobby, 'test user'); 42 | }); 43 | 44 | it('returns own user data on lobby enter', function (done) { 45 | 46 | expect(invitedClient).toBeTruthy(); 47 | 48 | invitedClient.on(gameEvents.server.enterLobbyStatus, function (result) { 49 | expect(result.user.id).toBe('test user'); 50 | done(); 51 | }); 52 | invitedClient.emit(gameEvents.client.enterLobby, 'test user'); 53 | }); 54 | 55 | it('allows to use the same name after previous user was disconnected', function (done) { 56 | var username = 'test user'; 57 | invitedClient.emit(gameEvents.client.enterLobby, username); 58 | 59 | invitedClient.disconnect(); 60 | invitedClient = require('socket.io-client')('http://localhost:3000', { 61 | forceNew: true 62 | }); 63 | 64 | invitedClient.on(gameEvents.server.lobbyUpdate, function (result) { 65 | expect(result.newUser.id).toBe(username); 66 | expect(result.users.length).toEqual(1); 67 | done(); 68 | }); 69 | 70 | invitedClient.emit(gameEvents.client.enterLobby, username); 71 | }); 72 | //it('doesn\'t allow to enter lobby with the same name more than once', function (done) { 73 | // 74 | // client.on(gameEvents.server.enterLobbyStatus, function (error) { 75 | // expect(error).toMatch('.+'); 76 | // done(); 77 | // }); 78 | // 79 | // client.emit(gameEvents.client.enterLobby, 'test user'); 80 | // client.emit(gameEvents.client.enterLobby, 'test user'); 81 | //}); 82 | // 83 | //var waitForEnterLobbyError = function (done) { 84 | // client.on(gameEvents.server.lobbyUpdate, function (users) { 85 | // expect(false).toBe(true); 86 | // done(); 87 | // }); 88 | // 89 | // client.on(gameEvents.server.enterLobbyStatus, function (error) { 90 | // expect(error).toMatch('.+'); 91 | // done(); 92 | // }); 93 | //}; 94 | // 95 | //it('doesn\'t allow to enter lobby with null name', function (done) { 96 | // waitForEnterLobbyError(done); 97 | // client.emit(gameEvents.client.enterLobby, null); 98 | //}); 99 | // 100 | //it('doesn\'t allow to enter lobby with empty name', function (done) { 101 | // waitForEnterLobbyError(done); 102 | // client.emit(gameEvents.client.enterLobby, ''); 103 | //}); 104 | // 105 | //it('doesn\'t allow to enter lobby with undefined name', function (done) { 106 | // waitForEnterLobbyError(done); 107 | // client.emit(gameEvents.client.enterLobby); 108 | //}); 109 | // 110 | //it('doesn\'t allow to invite by unsigned user', function (done) { 111 | // client.emit(gameEvents.client.enterLobby, 'test'); 112 | // var lobbyUsers; 113 | // client.on(gameEvents.server.lobbyUpdate, function (users) { 114 | // lobbyUsers = users; 115 | // var otherClient = utils.getClient(); 116 | // otherClient.on(gameEvents.server.invitationRequestStatus, function (result) { 117 | // expect(result.isSuccessful).toBe(false); 118 | // done(); 119 | // }); 120 | // var inviteId = _.find(lobbyUsers.users, {username: 'test'}).id; 121 | // otherClient.emit(gameEvents.client.invitationRequest, inviteId); 122 | // }); 123 | //}); 124 | // 125 | //it('doesn\'t allow to invite non-existing user', function (done) { 126 | // client.emit(gameEvents.client.enterLobby, 'test'); 127 | // 128 | // client.on(gameEvents.server.invitationRequestStatus, function (result) { 129 | // expect(result.isSuccessful).toBe(false); 130 | // done(); 131 | // }); 132 | // 133 | // client.emit(gameEvents.client.invitationRequest, 'non existing'); 134 | //}); 135 | // 136 | it('allows to invite a user', function (done) { 137 | var lobbyUsers, inviteId = 'test', otherId = 'other'; 138 | var gotStatus, gotFwd; 139 | var checkComplete = function(done) { 140 | if (gotStatus && gotFwd) { 141 | done(); 142 | } 143 | }; 144 | 145 | invitedClient.emit(gameEvents.client.enterLobby, inviteId); 146 | invitedClient.on(gameEvents.server.lobbyUpdate, function (result) { 147 | lobbyUsers = result.users; 148 | 149 | var otherClient = utils.getClient(); 150 | otherClient.on(gameEvents.server.lobbyUpdate, function (result) { 151 | otherClient.on(gameEvents.server.invitationRequestStatus, function (result) { 152 | expect(result.isSuccessful).toBe(true); 153 | }); 154 | 155 | otherClient.on(gameEvents.server.invitationResponse, function (result) { 156 | gotFwd = true; 157 | expect(result.accepted).toBe(true); 158 | 159 | checkComplete(done); 160 | }); 161 | 162 | invitedClient.on(gameEvents.server.invitationForward, function(result) { 163 | expect(result.invitation.from).toBe(otherId); 164 | expect(result.invitation.to).toBe(inviteId); 165 | 166 | invitedClient.on(gameEvents.server.invitationResponse, function (result) { 167 | gotStatus = true; 168 | expect(result.accepted).toBe(true); 169 | 170 | checkComplete(done); 171 | }); 172 | var invitationResponse = {accepted: true, invitation: result.invitation}; 173 | invitedClient.emit(gameEvents.client.invitationResponse, invitationResponse); 174 | }); 175 | otherClient.emit(gameEvents.client.invitationRequest, inviteId); 176 | }); 177 | otherClient.emit(gameEvents.client.enterLobby, otherId); 178 | }); 179 | }); 180 | 181 | }); -------------------------------------------------------------------------------- /game/LobbyService.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | , messageHelper = require('./messageHelper') 3 | , gameEvents = require('./gameEvents') 4 | , Lobby = require('./Lobby') 5 | , EventEmitter = require('events').EventEmitter; 6 | 7 | module.exports = function (io, gameService) { 8 | 9 | var _lobby = new Lobby(); 10 | var _games = []; 11 | 12 | var _findUserSocket = function (userId) { 13 | if (!userId) return; 14 | 15 | var sockets = io.sockets.sockets; 16 | return _.find(sockets, {username: userId}); 17 | }; 18 | 19 | var _leaveLobby = function (socket, startsPlay) { 20 | _lobby.leaveLobby(socket.username, startsPlay); 21 | socket.leave('lobby'); 22 | }; 23 | 24 | var _startGame = function (GameService) { 25 | return { 26 | with: function (emitter, sockets) { 27 | var game = new GameService(emitter, sockets); 28 | 29 | sockets.forEach(function (socket) { 30 | _games.push({userId: socket.username, game: game}); 31 | }); 32 | game.start(); 33 | return game; 34 | } 35 | } 36 | } 37 | 38 | var _endGameWith = function (userId) { 39 | var assignment = _.find(_games, {userId: userId}); 40 | if (assignment) { 41 | var game = assignment.game; 42 | _.remove(_games, {game: game}); 43 | 44 | game.end(); 45 | return game; 46 | } 47 | }; 48 | 49 | this.start = function () { 50 | 51 | io.on('connection', function (socket) { 52 | socket.emit("ping"); 53 | 54 | socket.on(gameEvents.client.enterLobby, function (username) { 55 | var result = _lobby.enterLobby(username); 56 | socket.emit(gameEvents.server.enterLobbyStatus, result); 57 | 58 | if (result.isSuccessful) { 59 | socket.username = username; 60 | socket.join('lobby'); 61 | 62 | var update = _.merge({newUser: result.user}, _lobby.getLobbyState()); 63 | io.to('lobby').emit(gameEvents.server.lobbyUpdate, update); 64 | } 65 | }); 66 | 67 | socket.on(gameEvents.client.invitationRequest, function (userID) { 68 | var result = _lobby.inviteUser(userID, socket.username); 69 | socket.emit(gameEvents.server.invitationRequestStatus, result); 70 | 71 | if (result.isSuccessful) { 72 | var otherSocket = _findUserSocket(userID); 73 | otherSocket.emit(gameEvents.server.invitationForward, result); 74 | } 75 | }); 76 | 77 | socket.on(gameEvents.client.invitationResponse, function (response) { 78 | this.onInvitationResponse(socket, response); 79 | }.bind(this)); 80 | 81 | socket.on(gameEvents.client.playSingle, function () { 82 | this.onPlaySingle(socket); 83 | }.bind(this)); 84 | 85 | socket.on(gameEvents.client.quitGame, function () { 86 | this.onQuit(socket); 87 | }.bind(this)); 88 | 89 | socket.on(gameEvents.client.signOut, function () { 90 | this.onSignOut(socket); 91 | }.bind(this)); 92 | 93 | socket.on('disconnect', function () { 94 | this.onSignOut(socket, true); 95 | }.bind(this)); 96 | }.bind(this)); 97 | }; 98 | 99 | this.onInvitationResponse = function (socket, response) { 100 | if (socket.username != response.invitation.to) { 101 | return socket.emit(gameEvents.server.invitationResponse, 102 | messageHelper.toResult(new Error('User ID mismatch in invitation response'))); 103 | } 104 | 105 | var result = _lobby.acceptInvitation(response.accepted, response.invitation); 106 | socket.emit(gameEvents.server.invitationResponse, result); 107 | 108 | if (result.isSuccessful) { 109 | var otherSocket = _findUserSocket(response.invitation.from); 110 | otherSocket.emit(gameEvents.server.invitationResponse, result); 111 | 112 | //join game 113 | _leaveLobby(socket, true); 114 | _leaveLobby(otherSocket, true); 115 | io.to('lobby').emit(gameEvents.server.lobbyUpdate, _lobby.getLobbyState()); 116 | 117 | setTimeout(function () { 118 | var emitter = new EventEmitter(); 119 | _startGame(gameService).with(emitter, [socket, otherSocket]); 120 | emitter.on(gameEvents.server.gameOver, this.onGameOver); 121 | }.bind(this), 1000); 122 | } 123 | }; 124 | 125 | this.onPlaySingle = function (socket) { 126 | _leaveLobby(socket, true); 127 | 128 | io.to('lobby').emit(gameEvents.server.lobbyUpdate, _lobby.getLobbyState()); 129 | setTimeout(function () { 130 | var emitter = new EventEmitter(); 131 | _startGame(gameService).with(emitter, [socket]); 132 | emitter.on(gameEvents.server.gameOver, this.onGameOver); 133 | }.bind(this), 1000); 134 | }; 135 | 136 | this.onQuit = function (socket) { 137 | var game = _endGameWith(socket.username); 138 | if (game) { 139 | game.getSockets().forEach(function (gameSocket) { 140 | if (gameSocket != socket) { 141 | gameSocket.emit(gameEvents.server.playerLeft, messageHelper.toResult('Player ' + socket.username + ' has left the game!')); 142 | } 143 | 144 | _lobby.enterLobby(gameSocket.username, true); 145 | gameSocket.join('lobby'); 146 | }); 147 | } 148 | socket.emit(gameEvents.server.quitGameStatus, messageHelper.OK()); 149 | io.to('lobby').emit(gameEvents.server.lobbyUpdate, _lobby.getLobbyState()); 150 | } 151 | 152 | this.onSignOut = function (socket, disconnect) { 153 | _leaveLobby(socket); 154 | var game = _endGameWith(socket.username); 155 | if (game) { 156 | var gameSockets = _.filter(game.getSockets(), function (gSocket) { 157 | return (gSocket != socket); 158 | }); 159 | 160 | if (gameSockets) { 161 | gameSockets.forEach(function (gameSocket) { 162 | gameSocket.emit(gameEvents.server.playerLeft, messageHelper.toResult('Player ' + socket.username + ' has left the game!')); 163 | 164 | _lobby.enterLobby(gameSocket.username, true); 165 | gameSocket.join('lobby'); 166 | }); 167 | } 168 | } 169 | 170 | if (!disconnect) { 171 | socket.emit(gameEvents.server.signOutStatus, messageHelper.OK()); 172 | } 173 | 174 | io.to('lobby').emit(gameEvents.server.lobbyUpdate, _lobby.getLobbyState()); 175 | } 176 | 177 | this.onGameOver = function (items) { 178 | 179 | _endGameWith(items[0].userId); 180 | 181 | items.forEach(function (item) { 182 | 183 | var socket = _findUserSocket(item.userId); 184 | if(socket) { 185 | var payload = item.payload; 186 | 187 | socket.emit(gameEvents.server.gameOver, messageHelper.toResult(payload)); 188 | 189 | _lobby.enterLobby(socket.username, true); 190 | socket.join('lobby'); 191 | } 192 | }); 193 | io.to('lobby').emit(gameEvents.server.lobbyUpdate, _lobby.getLobbyState()); 194 | } 195 | } 196 | ; -------------------------------------------------------------------------------- /test/spec/LobbySpec.js: -------------------------------------------------------------------------------- 1 | var LobbyService = require('../../game/LobbyService'); 2 | var customMatchers = require('../customMatchers'); 3 | var _ = require('lodash'); 4 | 5 | describe('Lobby', function () { 6 | 7 | var Lobby; 8 | beforeEach(function () { 9 | this.addMatchers(customMatchers); 10 | Lobby = LobbyService(); 11 | }); 12 | 13 | it('allows to enter lobby', function () { 14 | 15 | var username = 'test'; 16 | var result = Lobby.enterLobby(username); 17 | 18 | expect(result.isSuccessful).toBeTruthy(); 19 | expect(result.user.id).toEqual(username); 20 | var users = Lobby.getLobbyState().users; 21 | expect(users.length).toBe(1); 22 | expect(users).toContainWhere({id: username}) 23 | }); 24 | 25 | it('doesn\'t allow to enter lobby with the same name twice', function () { 26 | 27 | var username = 'test'; 28 | var result = Lobby.enterLobby(username); 29 | var result = Lobby.enterLobby(username); 30 | 31 | expect(result.isSuccessful).toBeFalsy(); 32 | expect(result.error).toBe('The username already exists.'); 33 | }); 34 | 35 | it('allows to leave lobby', function () { 36 | var username = 'test'; 37 | var result = Lobby.enterLobby(username); 38 | var result = Lobby.leaveLobby(username); 39 | 40 | expect(result.isSuccessful).toBeTruthy(); 41 | var users = Lobby.getLobbyState().users; 42 | expect(users.length).toBe(0); 43 | }); 44 | 45 | it('doesn\'t allow to leave by an unregistered user', function () { 46 | var username = 'test'; 47 | var result = Lobby.leaveLobby(username); 48 | 49 | expect(result.isSuccessful).toBeFalsy(); 50 | expect(result.error).toEqual('User with the given ID doesn\'t exist.'); 51 | }); 52 | 53 | it('allows to invite a user', function () { 54 | var username = 'test'; 55 | Lobby.enterLobby(username); 56 | 57 | var inviting = 'inviting'; 58 | Lobby.enterLobby(inviting); 59 | 60 | var result = Lobby.inviteUser(username, inviting); 61 | expect(result.isSuccessful).toBeTruthy(); 62 | expect(result.from).toEqual(inviting); 63 | expect(result.to).toEqual(username); 64 | }); 65 | 66 | it('doesn\'t allow to invite a unregistered user', function () { 67 | var username = 'test'; 68 | 69 | var inviting = 'inviting'; 70 | Lobby.enterLobby(inviting); 71 | 72 | var result = Lobby.inviteUser(username, inviting); 73 | expect(result.isSuccessful).toBeFalsy(); 74 | expect(result.error).toBeTruthy(); 75 | }); 76 | 77 | it('doesn\'t allow to send an invitation more than once', function () { 78 | var username = 'test'; 79 | Lobby.enterLobby(username); 80 | var inviting = 'inviting'; 81 | Lobby.enterLobby(inviting); 82 | 83 | Lobby.inviteUser(username, inviting); 84 | var result = Lobby.inviteUser(username, inviting); 85 | expect(result.isSuccessful).toBeFalsy(); 86 | expect(result.error).toEqual('The user has already an invitation from you.'); 87 | }); 88 | 89 | it('allows to accept an invitation', function () { 90 | var username = 'test'; 91 | Lobby.enterLobby(username); 92 | 93 | var inviting = 'inviting'; 94 | Lobby.enterLobby(inviting); 95 | 96 | Lobby.inviteUser(username, inviting); 97 | var result = Lobby.acceptInvitation(true, username, inviting); 98 | var users = Lobby.getLobbyState().users; 99 | expect(_.find(users, {id: inviting}).isPlaying).toBeTruthy(); 100 | expect(_.find(users, {id: username}).isPlaying).toBeTruthy(); 101 | }); 102 | 103 | //it('returns own user data on lobby enter', function (done) { 104 | // 105 | // expect(client).toBeTruthy(); 106 | // 107 | // client.on(gameEvents.server.enterLobbyStatus, function (user) { 108 | // expect(user.username).toBe('test user'); 109 | // done(); 110 | // }); 111 | // client.emit(gameEvents.client.enterLobby, 'test user'); 112 | //}); 113 | // 114 | //it('allows to use the same name after previous user was disconnected', function (done) { 115 | // 116 | // client.emit(gameEvents.client.enterLobby, 'test user'); 117 | // 118 | // client.disconnect(); 119 | // client = require('socket.io-client')('http://localhost:3000', { 120 | // forceNew: true 121 | // }); 122 | // 123 | // client.on(gameEvents.server.lobbyUpdate, function (users) { 124 | // expect(users).not.toBeNull(); 125 | // done(); 126 | // }); 127 | // 128 | // client.emit(gameEvents.client.enterLobby, 'test user'); 129 | //}); 130 | // 131 | // 132 | //it('disallows to enter lobby with the same name more than once', function (done) { 133 | // 134 | // client.on(gameEvents.server.enterLobbyStatus, function (error) { 135 | // expect(error).toMatch('.+'); 136 | // done(); 137 | // }); 138 | // 139 | // client.emit(gameEvents.client.enterLobby, 'test user'); 140 | // client.emit(gameEvents.client.enterLobby, 'test user'); 141 | //}); 142 | // 143 | //var waitForEnterLobbyError = function (done) { 144 | // client.on(gameEvents.server.lobbyUpdate, function (users) { 145 | // expect(false).toBe(true); 146 | // done(); 147 | // }); 148 | // 149 | // client.on(gameEvents.server.enterLobbyStatus, function (error) { 150 | // expect(error).toMatch('.+'); 151 | // done(); 152 | // }); 153 | //}; 154 | // 155 | //it('disallows to enter lobby with null name', function (done) { 156 | // waitForEnterLobbyError(done); 157 | // client.emit(gameEvents.client.enterLobby, null); 158 | //}); 159 | // 160 | //it('disallows to enter lobby with empty name', function (done) { 161 | // waitForEnterLobbyError(done); 162 | // client.emit(gameEvents.client.enterLobby, ''); 163 | //}); 164 | // 165 | //it('disallows to enter lobby with undefined name', function (done) { 166 | // waitForEnterLobbyError(done); 167 | // client.emit(gameEvents.client.enterLobby); 168 | //}); 169 | // 170 | //it('disallows to invite by unsigned user', function (done) { 171 | // client.emit(gameEvents.client.enterLobby, 'test'); 172 | // var lobbyUsers; 173 | // client.on(gameEvents.server.lobbyUpdate, function (users) { 174 | // lobbyUsers = users; 175 | // var otherClient = utils.getClient(); 176 | // otherClient.on(gameEvents.server.invitationRequestStatus, function (result) { 177 | // expect(result.isSuccessful).toBe(false); 178 | // done(); 179 | // }); 180 | // var inviteId = _.find(lobbyUsers.users, {username: 'test'}).id; 181 | // otherClient.emit(gameEvents.client.invitationRequest, inviteId); 182 | // }); 183 | //}); 184 | // 185 | //it('disallows to invite non-existing user', function (done) { 186 | // client.emit(gameEvents.client.enterLobby, 'test'); 187 | // 188 | // client.on(gameEvents.server.invitationRequestStatus, function (result) { 189 | // expect(result.isSuccessful).toBe(false); 190 | // done(); 191 | // }); 192 | // 193 | // client.emit(gameEvents.client.invitationRequest, 'non existing'); 194 | //}); 195 | // 196 | //it('allows to invite a user', function (done) { 197 | // var lobbyUsers; 198 | // client.emit(gameEvents.client.enterLobby, 'test'); 199 | // client.on(gameEvents.server.lobbyUpdate, function (users) { 200 | // lobbyUsers = users; 201 | // 202 | // var otherClient = utils.getClient(); 203 | // otherClient.emit(gameEvents.client.enterLobby, 'other'); 204 | // otherClient.on(gameEvents.server.lobbyUpdate, function (users) { 205 | // otherClient.on(gameEvents.server.invitationRequestStatus, function (result) { 206 | // expect(result.isSuccessful).toBe(true); 207 | // console.dir(result); 208 | // done(); 209 | // }); 210 | // var inviteId = _.find(lobbyUsers.users, {username: 'test'}).id; 211 | // otherClient.emit(gameEvents.client.invitationRequest, inviteId); 212 | // }) 213 | // }); 214 | //}); 215 | 216 | }); -------------------------------------------------------------------------------- /game/battleships/game.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash') 2 | , messageHelper = require('./../messageHelper') 3 | , gameEvents = require('./../gameEvents') 4 | , Ship = require('./../models/ship') 5 | , Position = require('./../models/position'); 6 | 7 | var GameUser = function (userId) { 8 | this.id = userId; 9 | this.ships = []; // contains the ships for the player 10 | this.intactShipsCount = undefined; // number of intact ships; on "0" current player lose 11 | this.shoots = []; // contains positions to which the player has shot 12 | 13 | this.hasShotOnThisPosition = function (shootPosition) { 14 | var result = _.find(this.shoots, shootPosition); 15 | return (result !== undefined); 16 | }; 17 | } 18 | 19 | var BattleshipsGame = function (emitter, userAId, userBId) { 20 | 21 | if (!userAId || !userBId) { 22 | throw new Error("One of user IDs is missing"); 23 | } 24 | 25 | this.userA = new GameUser(userAId); 26 | this.userB = new GameUser(userBId); 27 | 28 | emitter.on(gameEvents.client.placeShips, function (userId, shipsData) { 29 | this.placeShips(userId, shipsData); 30 | }.bind(this)); 31 | 32 | emitter.on(gameEvents.client.shoot, function (userId, position) { 33 | this.shoot(userId, position); 34 | }.bind(this)); 35 | 36 | this._getStartingUser = function () { 37 | // throw dice 38 | var diceNumber = getRandomNumber(1, 6); 39 | 40 | return (diceNumber < 4) 41 | ? this.sender : this.opponent; 42 | }; 43 | 44 | this.getConfig = function() { 45 | return { 46 | boardSize: 10, 47 | ships: [ 48 | {name: 'Battleship', size: 5, count: 1}, 49 | {name: 'Submarine', size: 4, count: 1}, 50 | {name: 'Cruiser', size: 3, count: 2}, 51 | {name: 'Destroyer', size: 2, count: 2}, 52 | {name: 'Patrol boat', size: 1, count: 2} 53 | ]}; 54 | }; 55 | 56 | this.start = function() { 57 | emitter.emit(this.userA.id, gameEvents.server.gameStarted, messageHelper.toResult({config: this.getConfig(), opponent: this.userB})); 58 | emitter.emit(this.userB.id, gameEvents.server.gameStarted, messageHelper.toResult({config: this.getConfig(), opponent: this.userA})); 59 | }; 60 | 61 | this.placeShips = function (senderId, ships) { 62 | this._setUsers(senderId); 63 | 64 | if (this.sender.ships.length > 0) { // has ships 65 | this._sendToSender(gameEvents.server.shipsPlaced, new Error('you\'ve already placed your ships!')); 66 | return; 67 | } 68 | 69 | // transform ships 70 | ships.forEach(function (ship) { 71 | var positions = []; 72 | ship.cells.forEach(function (cell) { 73 | positions.push(new Position(cell.x, cell.y)); 74 | }); 75 | 76 | this.sender.ships.push(new Ship(ship.id, positions)); 77 | }.bind(this)); 78 | this.sender.intactShipsCount = ships.length; 79 | 80 | if (this.opponent.ships.length > 0) { // opponent has placed his ships 81 | this._sendToSender(gameEvents.server.shipsPlaced, 'ships are placed'); 82 | 83 | setTimeout(function () { 84 | var startingUser = this._getStartingUser(); 85 | 86 | startingUser.hasTurn = true; 87 | this._sendToUser(startingUser.id, gameEvents.server.activatePlayer, 'you begin!'); 88 | this._sendToUser(((startingUser == this.sender) ? this.opponent : this.sender).id, 89 | gameEvents.server.infoMessage, startingUser.id + ' begins!'); 90 | }.bind(this), 1000); 91 | } 92 | else { // opponent isn't ready 93 | this._sendToSender(gameEvents.server.shipsPlaced, 'ships are placed. Waiting for ' + this.opponent.id + '...'); 94 | } 95 | 96 | this._sendToOpponent(gameEvents.server.infoMessage, this.sender.id + ' has placed his ships!'); 97 | }; 98 | 99 | this._sendToUser = function (userId, eventName, data) { 100 | if (!eventName) { 101 | throw new Error('"eventName" is undefined'); 102 | } 103 | if (!userId) { 104 | throw new Error('"userId" is undefined'); 105 | } 106 | 107 | emitter.emit(userId, eventName, messageHelper.toResult(data)); 108 | }; 109 | 110 | this._sendToSender = function (eventName, data) { 111 | this._sendToUser(this.sender.id, eventName, data); 112 | }; 113 | 114 | this._sendToOpponent = function (eventName, data) { 115 | this._sendToUser(this.opponent.id, eventName, data); 116 | }; 117 | 118 | this.shoot = function (senderId, position) { 119 | this._setUsers(senderId); 120 | 121 | if (!position) { 122 | return; 123 | } 124 | 125 | if (this.sender.ships.length == 0) { // no ships 126 | this._sendToSender(gameEvents.server.shotUpdate, new Error('Place ships first!')); 127 | return; 128 | } 129 | 130 | if (!this.sender.hasTurn) { 131 | this._sendToSender(gameEvents.server.shotUpdate, new Error('It\'s not you turn!')); 132 | return; 133 | } 134 | 135 | if (this.sender.hasShotOnThisPosition(position)) { 136 | this._sendToSender(gameEvents.server.shotUpdate, new Error('You can\'t shoot at this position!')); 137 | return; 138 | } 139 | 140 | this.sender.shoots.push(position); 141 | 142 | var result = this._checkForHit(this.opponent, position); 143 | this._sendToSender(gameEvents.server.shotUpdate, _.chain(result).merge({me: true}).omit(!result.shipWasDestroyed ? 'ship': '').value()); 144 | this._sendToOpponent(gameEvents.server.shotUpdate, _.merge(result, {me: false})); 145 | 146 | var iLost = (this.sender.intactShipsCount <= 0); 147 | var opponentLost = (this.opponent.intactShipsCount <= 0); 148 | var gameOver = iLost || opponentLost; 149 | if (gameOver) { 150 | setTimeout(function () { 151 | emitter.emit(gameEvents.server.gameOver,[ 152 | {userId: this.sender.id, payload: {hasWon: !iLost}}, 153 | {userId: this.opponent.id, payload: {hasWon: !opponentLost}} 154 | ]); 155 | }.bind(this), 1000); 156 | } 157 | else { 158 | this._switchPlayer(this.sender.id); 159 | } 160 | }; 161 | 162 | /** 163 | * Checks a shoot hit a ship. 164 | * @param shootPosition 165 | */ 166 | this._checkForHit = function (user, shootPosition) { 167 | 168 | for (var s = 0; s < user.ships.length; s++) { 169 | var ship = user.ships[s]; 170 | if (ship.healthCount > 0) { // ship is intact 171 | for (var p = 0; p < ship.positions.length; p++) { 172 | var shipPosition = ship.positions[p]; 173 | if (shipPosition.isDamaged) { 174 | continue; 175 | } 176 | 177 | if ((shipPosition.x === shootPosition.x) && (shipPosition.y === shootPosition.y)) { // hit ship 178 | shipPosition.isDamaged = true; 179 | ship.healthCount--; 180 | 181 | var shipWasDestroyed = false; 182 | 183 | if (ship.healthCount <= 0) { // ship was destroyed 184 | shipWasDestroyed = true; 185 | user.intactShipsCount--; 186 | } 187 | 188 | return { 189 | shipWasHit: true, 190 | shipWasDestroyed: shipWasDestroyed, 191 | ship: ship, 192 | position: {x: shipPosition.x, y: shipPosition.y} 193 | }; 194 | } 195 | } 196 | } 197 | } 198 | 199 | // no hit on a ship 200 | return { 201 | shipWasHit: false, 202 | shipWasDestroyed: false, 203 | position: {x: shootPosition.x, y: shootPosition.y} 204 | }; 205 | }; 206 | 207 | this._switchPlayer = function (senderId) { 208 | if (!this.sender.hasTurn) { //player is currently not playing 209 | return; 210 | } 211 | 212 | this.sender.hasTurn = false; 213 | this.opponent.hasTurn = true; 214 | 215 | this._sendToUser(this.sender.id, gameEvents.server.playerSwitched); 216 | this._sendToUser(this.opponent.id, gameEvents.server.activatePlayer, 'It\'s your turn!'); 217 | }; 218 | 219 | this._setUsers = function (eventUserId) { 220 | if (eventUserId != this.userA.id && eventUserId != this.userB.id) { 221 | throw new Error('User with ID \'' + eventUserId + '\' is not authorized to play this game'); 222 | } 223 | 224 | this.sender = (eventUserId == this.userA.id) ? this.userA : this.userB; 225 | this.opponent = (eventUserId == this.userA.id) ? this.userB : this.userA; 226 | } 227 | 228 | function getRandomNumber(min, max) { 229 | return Math.floor((Math.random() * max) + min); 230 | } 231 | }; 232 | 233 | module.exports = BattleshipsGame; 234 | 235 | -------------------------------------------------------------------------------- /game/battleships/Opponent.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var boardUtils = require('../BoardUtils'); 3 | var gameEvents = require('./../gameEvents'); 4 | 5 | var Opponent = function (userId, emitter) { 6 | 7 | var board, gameConfig, remainingShips; 8 | 9 | var init = function (config) { 10 | boardUtils.boardSize = config.boardSize; 11 | gameConfig = config; 12 | 13 | board = { 14 | inactive: [], 15 | hitCells: [], 16 | smallestShipLeft: 1 17 | }; 18 | 19 | remainingShips = _.cloneDeep(config.ships); 20 | }; 21 | 22 | var bindEvents = function (userId, emitter) { 23 | emitter.on(userId, function (event, result) { 24 | if (result.isSuccessful) { 25 | switch (event) { 26 | case gameEvents.server.gameStarted: 27 | var placement = placeShips(result.config); 28 | setTimeout(function () { 29 | emitter.emit(gameEvents.client.placeShips, userId, placement); 30 | }, 2000); 31 | break; 32 | case gameEvents.server.activatePlayer: 33 | var shot = shoot(board); 34 | setTimeout(function () { 35 | emitter.emit(gameEvents.client.shoot, userId, shot); 36 | }, 3500); 37 | break; 38 | case gameEvents.server.shotUpdate: 39 | if (result.me) { 40 | board = updateGameState(board, result); 41 | } 42 | break; 43 | } 44 | } 45 | }); 46 | }; 47 | 48 | var placeShips = function (config) { 49 | init(config); 50 | 51 | var ships = _.sortBy(config.ships, function (ship) { 52 | return -config.boardSize; 53 | }); 54 | var placement = []; 55 | var taken = {cells: []}; 56 | 57 | ships.forEach(function (ship) { 58 | for (var i = 0; i < ship.count; i++) { 59 | var newShip = placeShip(ship.size, taken, config.boardSize); 60 | if (newShip) { 61 | placement.push({id: ship.count + '' + ship.size, cells: newShip}); 62 | } 63 | } 64 | }.bind(this)); 65 | 66 | return placement; 67 | }; 68 | 69 | var placeShip = function (size, taken, boardSize) { 70 | do { 71 | var randomPos = {x: Math.floor(Math.random() * boardSize), y: Math.floor(Math.random() * boardSize)}; 72 | var vertical = Math.round(Math.random()); 73 | 74 | var ship = buildShip(randomPos, size, vertical, boardSize, taken); 75 | } 76 | while (!ship); 77 | 78 | return ship; 79 | }; 80 | 81 | var buildShip = function (topLeft, size, vertical, boardSize, taken) { 82 | 83 | if (_.any(taken.cells, {x: topLeft.x, y: topLeft.y})) return false; 84 | 85 | if (vertical) { 86 | if (topLeft.y + size - 1 >= boardSize) return false; 87 | } 88 | else { 89 | if (topLeft.x + size - 1 >= boardSize) return false; 90 | } 91 | 92 | var result = [topLeft]; 93 | for (var i = 1; i < size; i++) { 94 | var cell = { 95 | x: vertical ? topLeft.x : (topLeft.x + i), 96 | y: vertical ? (topLeft.y + i) : topLeft.y 97 | }; 98 | 99 | if (_.any(taken.cells, cell)) return false; 100 | 101 | result.push(cell); 102 | } 103 | 104 | taken.cells = taken.cells.concat(result); 105 | taken.cells = taken.cells.concat(boardUtils.getAdjacentCells(result)); 106 | 107 | return result; 108 | }; 109 | 110 | var shoot = function (board) { 111 | if (!board) { 112 | throw new Error("boardState is Empty"); 113 | } 114 | boardUtils.boardSize = gameConfig.boardSize; 115 | 116 | if (board.hitCells && board.hitCells.length > 0) { 117 | var index = 0; 118 | 119 | do { 120 | var hit = board.hitCells[index]; 121 | var adjacentCells = boardUtils.getAdjacentCells([hit], true); 122 | 123 | adjacentCells = _.filter(adjacentCells, function (adjacent) { 124 | return !_.any(board.inactive, function (takenShot) { 125 | return (takenShot.x == adjacent.x && takenShot.y == adjacent.y); 126 | }) 127 | }); 128 | 129 | var alongPreviousHit = hitAlongAdjacentHit(hit, board.hitCells, adjacentCells); 130 | if (alongPreviousHit) { 131 | return alongPreviousHit; 132 | } 133 | 134 | index += 1; 135 | } while (adjacentCells.length == 0 || index < board.hitCells.length); 136 | 137 | if (adjacentCells.length > 0) { 138 | return adjacentCells[0]; 139 | } 140 | } 141 | return takeRandomShot(board); 142 | }; 143 | 144 | var hitAlongAdjacentHit = function (hit, otherHits, adjacentCells) { 145 | if (adjacentCells.length > 0) { 146 | var left = hit.x > 0 ? {x: hit.x - 1, y: hit.y} : null; 147 | var right = (hit.x < gameConfig.boardSize - 1) ? {x: hit.x + 1, y: hit.y} : null; 148 | var top = hit.y > 0 ? {x: hit.x, y: hit.y - 1} : null; 149 | var bottom = (hit.y < gameConfig.boardSize - 1) ? {x: hit.x, y: hit.y + 1} : null; 150 | 151 | if (left && right && _.any(otherHits, left) && _.any(adjacentCells, right)) { 152 | return right; 153 | } 154 | if (left && right && _.any(otherHits, right) && _.any(adjacentCells, left)) { 155 | return left; 156 | } 157 | if (top && bottom && _.any(otherHits, top) && _.any(adjacentCells, bottom)) { 158 | return bottom; 159 | } 160 | if (top && bottom && _.any(otherHits, bottom) && _.any(adjacentCells, top)) { 161 | return top; 162 | } 163 | } 164 | }; 165 | 166 | var getFreePositions = function (board) { 167 | var result = []; 168 | for (var i = 0; i < gameConfig.boardSize; i++) { 169 | for (var j = 0; j < gameConfig.boardSize; j++) { 170 | var pos = {x: i, y: j}; 171 | if (!_.any(board.inactive, pos)) { 172 | result.push(pos); 173 | } 174 | } 175 | } 176 | return result; 177 | }; 178 | 179 | var takeRandomShot = function (board) { 180 | var freePositions = getFreePositions(board); 181 | var filtered = eliminateCertainMisses(freePositions, board); 182 | 183 | var index = Math.floor(Math.random() * filtered.length); 184 | return filtered[index]; 185 | }; 186 | 187 | var eliminateCertainMisses = function (freePositions, board) { 188 | if (!board.smallestShipLeft) { 189 | throw new Error('smallestShipLeft: undefined'); 190 | } 191 | 192 | if (board.smallestShipLeft <= 1) { 193 | return freePositions; 194 | } 195 | 196 | return _.filter(freePositions, function (cell) { 197 | var freeInLine = 1; 198 | //horiz: 199 | for (var i = cell.x - 1; i >= 0; i--) { 200 | if (!_.any(board.inactive, {x: i, y: cell.y})) { 201 | freeInLine += 1; 202 | if (freeInLine >= board.smallestShipLeft) { 203 | return true; 204 | } 205 | } 206 | else { 207 | break; 208 | } 209 | } 210 | for (var i = cell.x + 1; i < gameConfig.boardSize; i++) { 211 | if (!_.any(board.inactive, {x: i, y: cell.y})) { 212 | freeInLine += 1; 213 | if (freeInLine >= board.smallestShipLeft) { 214 | return true; 215 | } 216 | } 217 | else { 218 | break; 219 | } 220 | } 221 | 222 | //vert: 223 | freeInLine = 1; 224 | for (var i = cell.y - 1; i >= 0; i--) { 225 | if (!_.any(board.inactive, {x: cell.x, y: i})) { 226 | freeInLine += 1; 227 | if (freeInLine >= board.smallestShipLeft) { 228 | return true; 229 | } 230 | } 231 | else { 232 | break; 233 | } 234 | } 235 | for (var i = cell.y + 1; i < gameConfig.boardSize; i++) { 236 | if (!_.any(board.inactive, {x: cell.x, y: 1})) { 237 | freeInLine += 1; 238 | if (freeInLine >= board.smallestShipLeft) { 239 | return true; 240 | } 241 | } 242 | else { 243 | break; 244 | } 245 | } 246 | }) 247 | }; 248 | 249 | var updateGameState = function (board, shotUpdate) { 250 | if (shotUpdate.shipWasDestroyed) { 251 | board.inactive = board.inactive.concat(shotUpdate.ship.positions); 252 | board.inactive = board.inactive.concat(boardUtils.getAdjacentCells(shotUpdate.ship.positions)); 253 | board.inactive = _.uniq(board.inactive, function (cell) { 254 | return cell.x + '' + cell.y 255 | }); 256 | 257 | board.hitCells = _.remove(board.hitCells, function (cell) { 258 | _.any(shotUpdate.ship.positions, {x: cell.x, y: cell.y}) 259 | }); 260 | 261 | var remaining = _.find(remainingShips, {size: shotUpdate.ship.positions.length}); 262 | remaining.count -= 1; 263 | 264 | var smallestLeft = _(remainingShips) 265 | .sortBy(function (ship) { 266 | return ship.size; 267 | }) 268 | .filter(function (ship) { 269 | return ship.count > 0; 270 | }) 271 | .first(); 272 | board.smallestShipLeft = smallestLeft ? smallestLeft.size : 0; 273 | } 274 | else { 275 | if (shotUpdate.shipWasHit) { 276 | board.hitCells.unshift(shotUpdate.position); 277 | } 278 | 279 | board.inactive.push(shotUpdate.position); 280 | } 281 | 282 | return board; 283 | }; 284 | 285 | return { 286 | init: init, 287 | bindEvents: bindEvents, 288 | updateGameState: updateGameState, 289 | placeShips: placeShips, 290 | shoot: shoot 291 | }; 292 | }; 293 | 294 | module.exports = Opponent; 295 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /public/stylesheets/style.less: -------------------------------------------------------------------------------- 1 | @import "reset.css"; 2 | @import "reset.css"; 3 | @import "bootstrap-import"; 4 | @import "font-awesome.css"; 5 | @import "toast.min.css"; 6 | @import url(http://fonts.googleapis.com/css?family=Raleway:200,400,700,800); 7 | 8 | @font-face { 9 | font-family: army-rust; 10 | src: url("../fonts/army-rust.eot"), 11 | url('../fonts/army-rust.ttf'); 12 | } 13 | 14 | @height-footer: 40px; 15 | @height-header: 42px; 16 | @bg-hf: #333; 17 | 18 | @color-gameboard: #20B2AA; 19 | @color-alt: firebrick; 20 | @color-sidenav-hover: #F08080; 21 | 22 | @bg: #E9EAED; 23 | @bg-menu: #6B3B3B; 24 | @input-bg: @bg; 25 | 26 | @btn-primary-color: #fff; 27 | @btn-primary-bg: firebrick; 28 | @btn-primary-border: darken(@btn-primary-bg, 5%); 29 | 30 | @fill-ship: #B0C4DE; 31 | @fill-ship-hit: red; 32 | 33 | html, body { 34 | height: 100%; 35 | background: @bg; 36 | input[type="text"] { 37 | 38 | @media (max-width: @screen-xs-max) { 39 | width: 100%; 40 | } 41 | @media (min-width: @screen-sm-min) { 42 | width: 100%; 43 | max-width: @screen-xs-max; 44 | } 45 | 46 | background-color: whitesmoke; 47 | border: solid 1px #888; 48 | color: #333; 49 | padding: 0 8px 0 8px; 50 | } 51 | a { 52 | text-decoration: none; 53 | &:hover { 54 | text-decoration: none; 55 | } 56 | &:active { 57 | text-decoration: none; 58 | } 59 | &:focus { 60 | text-decoration: none; 61 | } 62 | } 63 | 64 | .clearfix { 65 | .clearfix(); 66 | } 67 | } 68 | 69 | @-webkit-keyframes opacity-blink { 70 | 0% { 71 | opacity: 1; 72 | } 73 | 100% { 74 | opacity: 0; 75 | } 76 | } 77 | 78 | @keyframes opacity-blink { 79 | 0% { 80 | opacity: 1; 81 | } 82 | 100% { 83 | opacity: 0; 84 | } 85 | } 86 | 87 | @keyframes blink { 88 | 0% { 89 | stroke: red; 90 | } 91 | 100% { 92 | stroke: gray; 93 | } 94 | } 95 | 96 | @-webkit-keyframes blink { 97 | 0% { 98 | stroke: red; 99 | } 100 | 100% { 101 | stroke: gray; 102 | } 103 | } 104 | 105 | @keyframes blink-config { 106 | 0% { 107 | fill: firebrick; 108 | } 109 | 100% { 110 | fill: gray; 111 | } 112 | } 113 | 114 | @-webkit-keyframes blink-config { 115 | 0% { 116 | fill: firebrick; 117 | } 118 | 100% { 119 | fill: gray; 120 | } 121 | } 122 | 123 | .blink-config { 124 | -webkit-animation: blink-config 1s linear infinite; 125 | -moz-animation: blink-config 1s linear infinite; 126 | -ms-animation: blink-config 1s linear infinite; 127 | -o-animation: blink-config 1s linear infinite; 128 | animation: blink-config 1s linear infinite; 129 | } 130 | 131 | .blink { 132 | -webkit-animation: blink 1s linear infinite; 133 | -moz-animation: blink 1s linear infinite; 134 | -ms-animation: blink 1s linear infinite; 135 | -o-animation: blink 1s linear infinite; 136 | animation: blink 1s linear infinite; 137 | } 138 | 139 | @opacity-blink-duration: 0.6s; 140 | @opacity-blink-iterations: 10; 141 | 142 | .opacity-blink { 143 | -webkit-animation: opacity-blink @opacity-blink-duration linear @opacity-blink-iterations; 144 | -moz-animation: opacity-blink @opacity-blink-duration linear @opacity-blink-iterations; 145 | -ms-animation: opacity-blink @opacity-blink-duration linear @opacity-blink-iterations; 146 | -o-animation: opacity-blink @opacity-blink-duration linear @opacity-blink-iterations; 147 | animation: opacity-blink @opacity-blink-duration linear @opacity-blink-iterations; 148 | } 149 | 150 | .tool-font { 151 | font-family: "Raleway", Arial, sans-serif; 152 | letter-spacing: initial; 153 | 154 | @media (max-width: @screen-xs-max) { 155 | font-size: 14px; 156 | } 157 | 158 | @media (min-width: @screen-sm-min) { 159 | font-size: 16px; 160 | } 161 | 162 | } 163 | 164 | #app { 165 | width: 100%; 166 | height: 100%; 167 | 168 | #react-root { 169 | min-height: 100%; 170 | position: relative; 171 | } 172 | 173 | .hf-links() { 174 | color: white; 175 | &:hover { 176 | color: lighten(@color-alt, 20%); 177 | } 178 | } 179 | 180 | .hf { 181 | background: @bg-hf; 182 | width: 100%; 183 | .tool-font(); 184 | 185 | span { 186 | color: lightgray; 187 | font-weight: 500; 188 | } 189 | 190 | a { 191 | font-weight: 700; 192 | .hf-links(); 193 | 194 | span { 195 | color: inherit; 196 | font-weight: inherit; 197 | } 198 | } 199 | } 200 | 201 | #header { 202 | height: @height-header; 203 | 204 | .header-controls() { 205 | color: @color-alt; 206 | &:hover { 207 | color: lighten(@color-alt, 20%); 208 | } 209 | } 210 | 211 | .nav-item-after() { 212 | background: gray; 213 | content: ''; 214 | height: 24px; 215 | left: 10px; 216 | position: relative; 217 | display: inline-block; 218 | top: 6px; 219 | width: 1px; 220 | } 221 | 222 | .user-id { 223 | padding: 14px 20px 5px 10px; 224 | display: inline-block; 225 | float: right; 226 | text-align: right; 227 | 228 | @media (max-width: @screen-xs-max) { 229 | width: 83%; 230 | } 231 | 232 | @media (min-width: @screen-sm-min) { 233 | width: 55%; 234 | } 235 | 236 | overflow: hidden; 237 | text-overflow: ellipsis; 238 | white-space: nowrap; 239 | 240 | .versus { 241 | color: red; 242 | } 243 | } 244 | 245 | #nav-btn { 246 | float: left; 247 | padding: 8px 8px 2px 8px; 248 | display: inline-block; 249 | .header-controls(); 250 | 251 | @media (min-width: @screen-sm-min) { 252 | display: none; 253 | } 254 | } 255 | 256 | #side-nav { 257 | @media (max-width: @screen-xs-max) { 258 | display: none; 259 | 260 | &.active-nav { 261 | display: block; 262 | } 263 | 264 | .pointer { 265 | position: absolute; 266 | left: 15px; 267 | top: @height-header; 268 | width: 0; 269 | height: 0; 270 | border-left: 10px solid transparent; 271 | border-right: 10px solid transparent; 272 | border-bottom: 10px solid @bg-menu; 273 | } 274 | } 275 | @media (min-width: @screen-sm-min) { 276 | display: inline-block; 277 | } 278 | 279 | #nav-list { 280 | @media (max-width: @screen-xs-max) { 281 | padding: 16px 8px 8px 20px; 282 | position: absolute; 283 | left: 3px; 284 | width: 250px; 285 | z-index: 100; 286 | margin-top: @height-header + 10px; 287 | -webkit-box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5); 288 | box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5); 289 | 290 | background: @bg-menu; 291 | 292 | li { 293 | line-height: 2.5em; 294 | a { 295 | font-size: 1.2em; 296 | font-weight: 500; 297 | text-decoration: none; 298 | span { 299 | margin-left: 10px; 300 | } 301 | 302 | color: white; 303 | &:hover { 304 | color: @color-sidenav-hover; 305 | } 306 | } 307 | } 308 | } 309 | @media (min-width: @screen-sm-min) { 310 | li { 311 | padding: 5px 10px 5px 10px; 312 | line-height: 1.5em; 313 | display: inline-block; 314 | a { 315 | font-size: 1.2em; 316 | font-weight: 500; 317 | text-decoration: none; 318 | span { 319 | margin-left: 10px; 320 | } 321 | 322 | color: white; 323 | &:hover { 324 | color: @color-sidenav-hover; 325 | } 326 | } 327 | 328 | &:after { 329 | .nav-item-after(); 330 | } 331 | } 332 | } 333 | } 334 | } 335 | } 336 | 337 | #footer { 338 | position: absolute; 339 | bottom: 0; 340 | width: 100%; 341 | height: @height-footer; 342 | padding: 8px 1px; 343 | text-align: center; 344 | 345 | .content { 346 | display: inline-block; 347 | vertical-align: bottom; 348 | } 349 | .social { 350 | display: inline-block; 351 | font-size: 1px; 352 | 353 | > div, > iframe { 354 | margin: 0 5px; 355 | vertical-align: top; 356 | } 357 | } 358 | 359 | /* This gets Facebook to fall into place */ 360 | .social iframe { 361 | vertical-align: middle; 362 | } 363 | 364 | /* Set an optional width for your button wrappers */ 365 | .social span { 366 | display: inline-block; 367 | } 368 | } 369 | 370 | #main { 371 | 372 | @media (min-width: @screen-sm-min) { 373 | padding: 10px 16px (@height-footer + 10px) 16px; 374 | } 375 | @media (max-width: @screen-xs-max) { 376 | padding: 5px 8px (@height-footer + 5px) 8px; 377 | } 378 | 379 | #game-header { 380 | div.title { 381 | display: inline-block; 382 | 383 | h1 { 384 | width: 100%; 385 | margin: 0; 386 | color: @color-alt; 387 | font-family: army-rust; 388 | } 389 | 390 | @media (max-aspect-ratio: 8/5) { 391 | width: 60%; 392 | h1 { 393 | letter-spacing: 0.6vw; 394 | font-size: 10vw; 395 | line-height: 11vw; 396 | } 397 | } 398 | @media (min-aspect-ratio: 8/5) { 399 | width: 70%; 400 | h1 { 401 | letter-spacing: 0.4vw; 402 | font-size: 8vw; 403 | line-height: 9vw; 404 | } 405 | } 406 | } 407 | 408 | div.logo { 409 | display: inline-block; 410 | vertical-align: top; 411 | 412 | @media (min-aspect-ratio: 8/5) { 413 | width: 30%; 414 | } 415 | @media (max-aspect-ratio: 8/5) { 416 | width: 40%; 417 | } 418 | img.logo { 419 | vertical-align: initial; 420 | width: 100%; 421 | } 422 | } 423 | } 424 | 425 | .sign-in { 426 | font-size: 16px; 427 | div { 428 | margin-top: 10px; 429 | } 430 | 431 | input { 432 | font-family: "Raleway", Arial, sans-serif; 433 | } 434 | 435 | button.btn { 436 | line-height: 1; 437 | } 438 | 439 | @media (min-width: @screen-sm-min) { 440 | font-size: 2em; 441 | button.btn { 442 | font-size: 2em; 443 | } 444 | } 445 | @media (max-width: @screen-xs-max) { 446 | font-size: 1.5em; 447 | button.btn { 448 | font-size: 1.5em; 449 | } 450 | } 451 | } 452 | 453 | #game { 454 | font: 24px army-rust, "Lucida Grande", Helvetica, Arial, sans-serif; 455 | 456 | .lobby { 457 | @bg-lobby: #debdbd; 458 | 459 | .tool-font(); 460 | 461 | .header { 462 | padding: 10px 0; 463 | } 464 | 465 | .single-player { 466 | margin: 1em 0; 467 | button { 468 | margin-left: 0.5em; 469 | } 470 | } 471 | 472 | .content { 473 | -webkit-box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5); 474 | box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5); 475 | 476 | .no-user { 477 | font-weight: 700; 478 | line-height: 2em; 479 | background: @bg-lobby; 480 | } 481 | 482 | @media (min-width: @screen-sm-min) { 483 | width: 80%; 484 | min-width: @screen-sm-min; 485 | } 486 | @media (min-width: @screen-md-min) { 487 | width: @screen-sm-min; 488 | } 489 | @media (max-width: @screen-xs-max) { 490 | width: 100%; 491 | } 492 | overflow: auto; 493 | 494 | .header { 495 | padding: 10px 5px 10px 5px; 496 | background: #B7C4EA; 497 | } 498 | 499 | .user-list { 500 | 501 | .user-item { 502 | padding: 5px; 503 | overflow: hidden; 504 | border: solid #B7C4EA; 505 | background: @bg-lobby; 506 | border-width: 1px 0 0 0; 507 | vertical-align: middle; 508 | 509 | .user-name, .user-invitation { 510 | display: inline-block; 511 | } 512 | .user-name { 513 | float: left; 514 | max-width: 65%; 515 | font-weight: 700; 516 | } 517 | .user-invitation { 518 | float: right; 519 | } 520 | } 521 | } 522 | } 523 | } 524 | 525 | div.setup { 526 | .clearfix(); 527 | .command, button { 528 | .tool-font(); 529 | } 530 | 531 | .command { 532 | margin-bottom: 10px; 533 | font-weight: 700; 534 | } 535 | 536 | .confirm { 537 | button { 538 | float: right; 539 | padding: 1em 0.5em; 540 | &:enabled { 541 | color: #fff; 542 | background: @color-alt; 543 | } 544 | } 545 | } 546 | 547 | @media (min-aspect-ratio: 1/1) { 548 | width: 100%; 549 | .side { 550 | width: 40%; 551 | float: right; 552 | 553 | .ships-panel { 554 | width: 100%; 555 | 556 | svg { 557 | min-height: 25vh; 558 | } 559 | } 560 | } 561 | 562 | .board.setup { 563 | width: 60%; 564 | 565 | svg { 566 | height: 65vh; 567 | } 568 | } 569 | } 570 | 571 | @media (max-aspect-ratio: 1/1) { 572 | width: 100%; 573 | 574 | .board.setup { 575 | svg { 576 | height: 90vw; 577 | } 578 | } 579 | 580 | .side { 581 | width: 100%; 582 | float: left; 583 | 584 | .ships-panel { 585 | float: left; 586 | width: 80%; 587 | } 588 | .confirm { 589 | float: right; 590 | width: 20%; 591 | } 592 | } 593 | } 594 | 595 | .ships-panel { 596 | letter-spacing: 0.1vw; 597 | font-size: 4vw; 598 | } 599 | } 600 | 601 | #shooting-panel { 602 | position: relative; 603 | .clearfix(); 604 | 605 | > div { 606 | padding: 5px; 607 | } 608 | 609 | g.cell.shot.update g { 610 | .opacity-blink(); 611 | } 612 | 613 | g.ship.update { 614 | .opacity-blink(); 615 | } 616 | 617 | @media screen { 618 | .pb { 619 | box-sizing: border-box; 620 | 621 | p.status { 622 | text-align: center; 623 | 624 | span.shoot { 625 | color: @color-alt; 626 | 627 | -webkit-animation: turn-blink 0.8s linear infinite; 628 | -moz-animation: turn-blink 0.8s linear infinite; 629 | -ms-animation: turn-blink 0.8s linear infinite; 630 | -o-animation: turn-blink 0.8s linear infinite; 631 | animation: turn-blink 0.8s linear infinite; 632 | } 633 | } 634 | } 635 | 636 | overflow-x: hidden; 637 | /* svg {height: } - to prevent IE sizing glitches from IE */ 638 | .pb { 639 | @media (min-aspect-ratio: 1/1) { 640 | display: inline-block; 641 | &.opponent { 642 | width: 43%; 643 | } 644 | 645 | &.me { 646 | width: 43%; 647 | float: right; 648 | } 649 | 650 | svg { 651 | height: 36vw; 652 | } 653 | 654 | @media(max-width: @screen-md-min) { 655 | 656 | &.opponent { 657 | width: 49%; 658 | } 659 | 660 | &.me { 661 | width: 49%; 662 | float: right; 663 | } 664 | 665 | svg { 666 | height: 46vw; 667 | } 668 | } 669 | 670 | p.status { 671 | font-size: 4.5vw; 672 | } 673 | } 674 | 675 | @media (max-aspect-ratio: 1/1) { 676 | width: 100%; 677 | transition: 1.0s all ease; 678 | transition-delay: 1s; 679 | 680 | &.switched { 681 | transition-delay: 0s; 682 | } 683 | 684 | &.me { 685 | float: right; 686 | margin-right: -102%; 687 | &.my-board-active { 688 | margin-right: 0%; 689 | } 690 | } 691 | 692 | &.opponent { 693 | float: left; 694 | margin-left: 0%; 695 | &.my-board-active { 696 | margin-left: -102%; 697 | } 698 | } 699 | 700 | svg { 701 | height: 90vw; 702 | width: 100%; 703 | } 704 | 705 | p.status { 706 | font-size: 7vw; 707 | letter-spacing: 0.05em; 708 | } 709 | } 710 | } 711 | } 712 | } 713 | 714 | .overlay { 715 | position: absolute; 716 | top: 0; 717 | left: 0; 718 | width: 100%; 719 | height: 100%; 720 | z-index: 10; 721 | background-color: rgba(0, 0, 0, 0.3); /*dim the background*/ 722 | display: flex; 723 | align-items: center; 724 | justify-content: center; 725 | 726 | .turn-overlay-text { 727 | font-size: 12vw; 728 | color: rgb(107, 12, 12); 729 | 730 | -webkit-animation: turn-blink 0.8s linear infinite; 731 | -moz-animation: turn-blink 0.8s linear infinite; 732 | -ms-animation: turn-blink 0.8s linear infinite; 733 | -o-animation: turn-blink 0.8s linear infinite; 734 | animation: turn-blink 0.8s linear infinite; 735 | } 736 | 737 | @keyframes turn-blink { 738 | 0% { 739 | opacity: 0.3; 740 | } 741 | 100% { 742 | opacity: 1; 743 | } 744 | } 745 | @-webkit-keyframes turn-blink { 746 | 0% { 747 | opacity: 0.1; 748 | } 749 | 100% { 750 | opacity: 0.6; 751 | } 752 | } 753 | } 754 | 755 | .switch-wrapper { 756 | 757 | #switch-board { 758 | @media (min-aspect-ratio: 1/1) { 759 | display: none; 760 | } 761 | 762 | width: 100%; 763 | font-size: 7vw; 764 | font-weight: 700; 765 | font-family: "Raleway", Arial, sans-serif; 766 | @btn-primary-bg: gray; 767 | @btn-primary-border: darken(gray, 5%); 768 | } 769 | } 770 | 771 | .game-over { 772 | font-size: 3em; 773 | 774 | p.win { 775 | color: rgba(76, 174, 76, 1); 776 | } 777 | 778 | button { 779 | .tool-font(); 780 | } 781 | } 782 | } 783 | 784 | .ships-panel { 785 | float: right; 786 | } 787 | 788 | //shot cell without ship 789 | g.cell { 790 | &.shot { 791 | line { 792 | stroke: red; 793 | stroke-width: 0.5; 794 | stroke-linecap: round; 795 | } 796 | &.adjacent { 797 | line { 798 | stroke: gray; 799 | } 800 | } 801 | } 802 | } 803 | 804 | //regular cell 805 | rect.cell { 806 | fill: @color-gameboard; 807 | stroke: gray; 808 | stroke-width: 0.2; 809 | } 810 | 811 | .pb.me { 812 | rect.cell { 813 | fill: #88A5A4; 814 | } 815 | } 816 | 817 | g.coord { 818 | rect { 819 | fill: white; 820 | stroke: gray; 821 | stroke-width: 0.2; 822 | } 823 | 824 | text { 825 | font-size: 6pt; 826 | } 827 | } 828 | 829 | g.ship { 830 | fill: @fill-ship; 831 | stroke: black; 832 | stroke-width: 0.5; 833 | 834 | &.selected { 835 | stroke: red; 836 | } 837 | } 838 | 839 | g.hit { 840 | rect { 841 | fill: @fill-ship-hit; 842 | } 843 | } 844 | 845 | rect.hit { 846 | fill: @fill-ship-hit; 847 | } 848 | 849 | .pb.opponent { 850 | 851 | g.ship { 852 | fill: #41F46E; 853 | } 854 | 855 | rect.hit { 856 | stroke: black; 857 | stroke-width: 0.5; 858 | fill: @fill-ship-hit; 859 | } 860 | } 861 | 862 | g.config { 863 | fill: #B0C4DE; 864 | stroke: black; 865 | stroke-width: 0.2; 866 | font-size: 9px; 867 | 868 | text { 869 | fill: #333; 870 | } 871 | 872 | &.selected { 873 | text { 874 | fill: firebrick; 875 | } 876 | } 877 | 878 | &.inactive { 879 | fill: #F8F8FF; 880 | opacity: 0.4; 881 | } 882 | } 883 | 884 | .setup.board { 885 | g.selected { 886 | .blink(); 887 | } 888 | } 889 | 890 | .ship-popup { 891 | background: red; 892 | padding: 100px; 893 | } 894 | } 895 | } 896 | 897 | /*Toastr animations*/ 898 | .animated { 899 | -webkit-animation-duration: 0.8s; 900 | animation-duration: 0.8s; 901 | -webkit-animation-fill-mode: both; 902 | animation-fill-mode: both; 903 | } 904 | 905 | @-webkit-keyframes fadeIn { 906 | 0% { 907 | opacity: 0; 908 | } 909 | 100% { 910 | opacity: 0.8; 911 | } 912 | } 913 | 914 | @keyframes fadeIn { 915 | 0% { 916 | opacity: 0; 917 | } 918 | 100% { 919 | opacity: 0.8; 920 | } 921 | } 922 | 923 | .fadeIn { 924 | -webkit-animation-name: fadeIn; 925 | animation-name: fadeIn; 926 | } 927 | 928 | @-webkit-keyframes fadeOut { 929 | 0% { 930 | opacity: 0.8; 931 | } 932 | 100% { 933 | opacity: 0; 934 | } 935 | } 936 | 937 | @keyframes fadeOut { 938 | 0% { 939 | opacity: 0.8; 940 | } 941 | 100% { 942 | opacity: 0; 943 | } 944 | } 945 | 946 | .fadeOut { 947 | -webkit-animation-name: fadeOut; 948 | animation-name: fadeOut; 949 | } 950 | 951 | #overlay { 952 | visibility: hidden; 953 | position: absolute; 954 | left: 0px; 955 | top: 0px; 956 | width: 100%; 957 | height: 100%; 958 | text-align: center; 959 | z-index: 1000; 960 | 961 | &.visible { 962 | visibility: visible; 963 | background: rgba(0, 0, 0, 0.5); 964 | } 965 | 966 | .box { 967 | .tool-font(); 968 | font-weight: 700; 969 | width: 40%; 970 | min-width: 250px; 971 | min-height: 100px; 972 | margin: 100px auto; 973 | background-color: #fff; 974 | border: 1px solid #000; 975 | text-align: center; 976 | -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 977 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 978 | border-radius: 7px; 979 | 980 | .question { 981 | padding: 15px; 982 | } 983 | 984 | .buttons { 985 | margin-top: 15px; 986 | border-top: 1px solid #e5e5e5; 987 | padding: 10px 15px 15px 15px; 988 | text-align: right; 989 | 990 | button { 991 | margin-left: 10px; 992 | } 993 | } 994 | } 995 | } --------------------------------------------------------------------------------