├── .babelrc
├── .eslintrc
├── .gitignore
├── .travis.yml
├── LICENSE.md
├── README.md
├── package.json
├── src
├── app.js
├── components
│ ├── BoardSide
│ │ ├── BoardSide.js
│ │ └── BoardSide.scss
│ ├── Card
│ │ ├── Card.js
│ │ └── Card.scss
│ ├── CardBack
│ │ ├── CardBack.js
│ │ └── CardBack.scss
│ ├── Countdown
│ │ ├── Countdown.js
│ │ └── Countdown.scss
│ ├── FormInput
│ │ └── FormInput.js
│ ├── FormInputGroup
│ │ └── FormInputGroup.js
│ ├── GameLobby
│ │ ├── GameLobby.js
│ │ └── GameLobby.scss
│ ├── Hand
│ │ ├── Hand.js
│ │ └── Hand.scss
│ ├── Hero
│ │ └── Hero.js
│ ├── InvitePlayerModal
│ │ └── InvitePlayerModal.js
│ ├── Minion
│ │ ├── Minion.js
│ │ └── Minion.scss
│ ├── MinionsOnBoard
│ │ ├── MinionsOnBoard.js
│ │ └── MinionsOnBoard.scss
│ ├── Modal
│ │ ├── Modal.js
│ │ └── Modal.scss
│ ├── Opponent
│ │ ├── Opponent.js
│ │ └── Opponent.scss
│ ├── OpponentHand
│ │ └── OpponentHand.js
│ ├── Player
│ │ ├── Player.js
│ │ └── Player.scss
│ ├── PlayerCard
│ │ ├── PlayerCard.js
│ │ └── PlayerCard.scss
│ ├── PlayerSide
│ │ ├── PlayerSide.js
│ │ └── PlayerSide.scss
│ ├── index.js
│ └── shared
│ │ ├── Stats.scss
│ │ └── styles.scss
├── containers
│ ├── App
│ │ └── App.js
│ ├── Board
│ │ ├── Board.js
│ │ └── Board.scss
│ ├── BoardSideDropTarget
│ │ └── BoardSideDropTarget.js
│ ├── CountdownManager
│ │ └── CountdownManager.js
│ ├── CustomDragLayer
│ │ └── CustomDragLayer.js
│ ├── DraggableCard
│ │ └── DraggableCard.js
│ ├── DraggableMinion
│ │ └── DraggableMinion.js
│ ├── MinionDropTarget
│ │ └── MinionDropTarget.js
│ ├── NewPlayerForm
│ │ └── NewPlayerForm.js
│ ├── Root
│ │ └── Root.js
│ ├── SocketProvider
│ │ └── SocketProvider.js
│ ├── TargetableHero
│ │ └── TargetableHero.js
│ ├── TargetableMinion
│ │ └── TargetableMinion.js
│ └── index.js
├── helpers
│ ├── getDisplayName.js
│ └── index.js
├── hoc
│ ├── index.js
│ └── withSocket.js
├── index.html
├── redux
│ ├── configureStore.js
│ ├── middlewares
│ │ ├── emitToOpponent.js
│ │ └── persistPlayerName.js
│ ├── modules
│ │ ├── board.js
│ │ ├── card.js
│ │ ├── character.js
│ │ ├── currentGame.js
│ │ ├── deck.js
│ │ ├── entities.js
│ │ ├── friendInviteModal.js
│ │ ├── game.js
│ │ ├── hand.js
│ │ ├── lobby.js
│ │ ├── minion.js
│ │ ├── name.js
│ │ ├── opponent.js
│ │ ├── player.js
│ │ ├── ready.js
│ │ ├── rootReducer.js
│ │ └── yourTurn.js
│ └── utils
│ │ ├── api.js
│ │ ├── cards.js
│ │ ├── createReducer.js
│ │ ├── dispatchNewGameAction.js
│ │ ├── dispatchServerActions.js
│ │ ├── getInitialState.js
│ │ └── index.js
├── styles
│ ├── app.scss
│ ├── base
│ │ ├── base.scss
│ │ └── forms.scss
│ └── vendor
│ │ └── reset.css
└── views
│ ├── GameLobbyScreen
│ └── GameLobbyScreen.js
│ ├── GameNewScreen
│ └── GameNewScreen.js
│ ├── GameScreen
│ └── GameScreen.js
│ ├── StartScreen
│ └── StartScreen.js
│ └── index.js
├── webpack
├── handlers
│ ├── index.js
│ ├── onAction.js
│ ├── onGameJoin.js
│ ├── onGameLeave.js
│ └── onGameStart.js
├── routes
│ └── api
│ │ └── game.js
├── server.js
├── utils
│ └── index.js
└── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["env", "react", "stage-1"],
3 | "plugins": ["transform-export-extensions"]
4 | }
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "env": {
5 | "browser": true,
6 | },
7 | "rules": {
8 | "react/prefer-stateless-function": 1,
9 | "react/require-extension": "off",
10 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
11 | // https://github.com/yannickcr/eslint-plugin-react/issues/861
12 | "react/no-unused-prop-types": 0,
13 | // until we've fixed using refs instead of using findDOMNode
14 | // Possible solution: https://github.com/gaearon/react-dnd/issues/329
15 | "react/no-find-dom-node": 1,
16 | },
17 | "settings": {
18 | "import/resolver": {
19 | "webpack": {
20 | "config": "./webpack/webpack.config.js"
21 | }
22 | }
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
18 | .grunt
19 |
20 | # node-waf configuration
21 | .lock-wscript
22 |
23 | # Compiled binary addons (http://nodejs.org/api/addons.html)
24 | build/Release
25 |
26 | # Dependency directories
27 | node_modules
28 | jspm_packages
29 |
30 | # Optional npm cache directory
31 | .npm
32 |
33 | # Optional REPL history
34 | .node_repl_history
35 | dist
36 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js: stable
4 |
5 | cache:
6 | directories:
7 | - node_modules
8 |
9 | install:
10 | - npm install
11 |
12 | script:
13 | # - npm run lint
14 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Boyd Dames
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | React with Redux -- Card Game (WIP)
2 | =============================
3 |
4 | [](https://travis-ci.org/inooid/react-redux-card-game)
5 | [](https://codeclimate.com/github/inooid/react-redux-card-game)
6 | [](https://snyk.io/test/github/inooid/react-redux-card-game)
7 |
8 | In order to develop my abstract thinking I figured it would be cool to make a game.
9 | Recently I have been playing a lot of [Hearthstone: Heroes of Warcraft](http://us.battle.net/en/int?r=hearthstone)
10 | and I realised that it would be great practice to rebuild it using [React](https://facebook.github.io/react/) with [Redux](http://redux.js.org/).
11 |
12 | Follow me in the journey that will be rebuilding this amazing card game.
13 |
14 | I am in no way affiliated with *Blizzard Entertainment* or *Hearthstone: Heroes of Warcraft* and see this
15 | project as part of my learning experience.
16 |
17 | Requirements
18 | ------------
19 |
20 | * node `^4.2.0`
21 | * npm `^3.0.0`
22 |
23 | Getting Started
24 | ---------------
25 |
26 | Just clone the repo and install the necessary node modules:
27 |
28 | ```shell
29 | $ git clone https://github.com/inooid/react-redux-card-game.git
30 | $ cd react-redux-card-game
31 | $ npm install # Install Node modules listed in ./package.json (may take a while the first time)
32 | $ npm start # Compile and launch
33 | $ open http://localhost:3000
34 | ```
35 |
36 | Current progress:
37 | -----------------
38 |
39 | Updated: 18 February 2018
40 |
41 | [](https://gfycat.com/WindyIndelibleKawala)
42 |
43 | (Click on the image for a bigger version)
44 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-redux-card-game",
3 | "version": "0.1.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node ./webpack/server.js",
8 | "test": "echo \"Error: no test specified\"",
9 | "lint": "eslint src"
10 | },
11 | "author": "Boyd Dames",
12 | "license": "MIT",
13 | "dependencies": {
14 | "classnames": "2.2.5",
15 | "express": "4.14.1",
16 | "immutable": "3.8.1",
17 | "react": "15.4.2",
18 | "react-dnd": "2.2.3",
19 | "react-dnd-html5-backend": "2.2.3",
20 | "react-dom": "15.4.2",
21 | "react-modal": "1.6.5",
22 | "react-portal": "^4.1.2",
23 | "react-redux": "5.0.2",
24 | "react-router": "3.0.2",
25 | "redux": "3.7.2",
26 | "redux-thunk": "2.2.0",
27 | "reselect": "2.5.4",
28 | "socket.io": "1.7.2",
29 | "socket.io-client": "1.7.2",
30 | "uuid": "3.0.1"
31 | },
32 | "devDependencies": {
33 | "babel-core": "6.26.0",
34 | "babel-eslint": "8.2.2",
35 | "babel-loader": "7.1.4",
36 | "babel-plugin-transform-export-extensions": "6.22.0",
37 | "babel-preset-env": "1.6.1",
38 | "babel-preset-react": "6.24.1",
39 | "babel-preset-stage-1": "6.24.1",
40 | "chalk": "1.1.3",
41 | "css-loader": "0.28.11",
42 | "eslint": "3.15.0",
43 | "eslint-config-airbnb": "11.2.0",
44 | "eslint-import-resolver-webpack": "0.8.1",
45 | "eslint-plugin-import": "1.16.0",
46 | "eslint-plugin-jsx-a11y": "2.2.2",
47 | "eslint-plugin-react": "6.9.0",
48 | "html-webpack-plugin": "3.2.0",
49 | "node-sass": "4.5.0",
50 | "postcss-loader": "2.1.3",
51 | "sass-loader": "6.0.7",
52 | "style-loader": "0.20.3",
53 | "webpack": "4.5.0",
54 | "webpack-dev-middleware": "3.1.2",
55 | "webpack-hot-middleware": "2.22.0"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import socketClient from 'socket.io-client';
4 |
5 | import configureStore from 'redux/configureStore';
6 | import {
7 | dispatchNewGameAction,
8 | dispatchServerActions,
9 | getInitialState,
10 | } from 'redux/utils';
11 |
12 | import { Root } from './containers';
13 | import './styles/app.scss';
14 |
15 | if (module.hot) {
16 | module.hot.accept();
17 | }
18 |
19 | const socket = socketClient('http://localhost:3000');
20 | const store = configureStore(getInitialState(), socket);
21 | dispatchServerActions(store, socket);
22 | dispatchNewGameAction(store, socket);
23 |
24 | ReactDOM.render(
25 | ,
26 | document.getElementById('app')
27 | );
28 |
--------------------------------------------------------------------------------
/src/components/BoardSide/BoardSide.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import styles from './BoardSide.scss';
3 |
4 | const BoardSide = ({ children }) => (
5 |
6 | { children }
7 |
8 | );
9 |
10 | BoardSide.propTypes = {
11 | children: PropTypes.node.isRequired,
12 | };
13 |
14 | export default BoardSide;
15 |
--------------------------------------------------------------------------------
/src/components/BoardSide/BoardSide.scss:
--------------------------------------------------------------------------------
1 | .BoardSide {
2 | width: 100%;
3 | height: 30%;
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/Card/Card.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import classNames from 'classnames';
3 | import { CardModel } from 'redux/modules/card';
4 |
5 | import styles from './Card.scss';
6 |
7 | const Card = ({ card, className, isDragging }) => {
8 | const { name, mana, attack, defense, portrait } = card;
9 | const cardClass = classNames(styles.Card, {
10 | [styles.CardIsDragging]: isDragging,
11 | [className]: className,
12 | });
13 |
14 | return (
15 |
16 |
20 |
{ mana || 0 }
21 |
{ name }
22 | { attack ?
{ attack }
: null }
23 | { defense ?
{ defense }
: null }
24 |
25 | );
26 | };
27 |
28 | Card.propTypes = {
29 | card: PropTypes.instanceOf(CardModel).isRequired,
30 | isDragging: PropTypes.bool,
31 | className: PropTypes.string,
32 | };
33 |
34 | export default Card;
35 |
--------------------------------------------------------------------------------
/src/components/Card/Card.scss:
--------------------------------------------------------------------------------
1 | @import '../shared/Stats';
2 |
3 | .Card {
4 | background: #776557;
5 | transition: transform 0.15s;
6 | position: relative;
7 | width: 35vh;
8 | height: 47vh;
9 | box-shadow: 0px 0px 20px rgba(0,0,0,0.3);
10 | border-radius: 8px;
11 | }
12 |
13 | .CardCanDrag {
14 | border: 2px solid rgb(0, 255, 0);
15 | box-shadow: 0px 0px 20px rgb(0, 255, 0);
16 | }
17 |
18 | .CardYours {
19 | transform: scale(0.4) translateY(25%);
20 | cursor: pointer;
21 |
22 | &:hover {
23 | transform: translateY(-35%);
24 | z-index: 1;
25 | }
26 | }
27 |
28 | .CardOpponent {
29 | background: transparent;
30 | transform: scale(0.60) translateY(-60%);
31 | }
32 |
33 | .CardName {
34 | position: absolute;
35 | width: 100%;
36 | top: 50%;
37 | background: #D4B48F;
38 | text-align: center;
39 | font-size: 3vh;
40 | color: #ffffff;
41 | padding: 10px;
42 | box-sizing: border-box;
43 | text-shadow: 0px 0px 6px rgba(0, 0, 0, 0.5);
44 | }
45 |
46 | .CardPortrait {
47 | position: absolute;
48 | top: -10px;
49 | left: 15%;
50 | width: 70%;
51 | height: 70%;
52 | border: 4px solid #968E86;
53 | border-radius: 100%;
54 | background-size: cover;
55 | background-position: center;
56 | }
57 |
58 | $stats-distance: 10px;
59 | $stats-padding: 12px 16px;
60 |
61 | .CardMana {
62 | @extend %stats;
63 | padding: $stats-padding;
64 | background: $stat-mana-color;
65 | top: -$stats-distance;
66 | left: -$stats-distance;
67 | }
68 |
69 | .CardAttack {
70 | @extend %stats;
71 | padding: $stats-padding;
72 | background: $stat-attack-color;
73 | bottom: -$stats-distance;
74 | left: -$stats-distance;
75 | }
76 |
77 | .CardDefense {
78 | @extend %stats;
79 | padding: $stats-padding;
80 | background: $stat-defense-color;
81 | bottom: -$stats-distance;
82 | right: -$stats-distance;
83 | }
84 |
85 | @function fix-card-alignment($this-card, $total-cards) {
86 | $half: $total-cards / 2;
87 | $percentage: 0;
88 |
89 | @if $this-card < $half { $percentage: -7% * $this-card; }
90 | @else { $percentage: -7% * (($total-cards - 1) - $this-card) }
91 |
92 | @return -30% + $percentage;
93 | }
94 |
95 | @function curve($this-card, $total-cards, $range) {
96 | $base: -$range / 2;
97 | $modifier: if(($total-cards - 1) == 0, 0, $this-card / ($total-cards - 1));
98 |
99 | @return $base + ($range * $modifier);
100 | }
101 |
102 | @for $max from 1 through 10 {
103 | $range: 50deg * ($max / 10);
104 | .CardTotal-#{$max} {
105 | margin: 0 -13vh;
106 | }
107 |
108 | @if $max != 1 {
109 | @for $i from 0 through $max {
110 | .CardNumber-#{$i + 1}-of-#{$max} {
111 | transform: scale(0.4) rotate(curve($i, $max, $range)) translateY(fix-card-alignment($i, $max));
112 | transform-origin: center bottom;
113 | }
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/CardBack/CardBack.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import classnames from 'classnames';
3 |
4 | import cardStyles from './../Card/Card.scss';
5 | import cardBackStyles from './CardBack.scss';
6 |
7 | const CardBack = ({ margin, className }) => {
8 | const marginStyle = `-${margin}px`;
9 | const classes = classnames(cardStyles.Card, cardBackStyles.CardBackDefault, className);
10 |
11 | return ;
12 | };
13 |
14 | CardBack.propTypes = {
15 | margin: PropTypes.number.isRequired,
16 | className: PropTypes.string,
17 | };
18 |
19 | export default CardBack;
20 |
--------------------------------------------------------------------------------
/src/components/CardBack/CardBack.scss:
--------------------------------------------------------------------------------
1 | .CardBackDefault {
2 | background: #6277a6;
3 | border: 8px solid #d6b03e;
4 | box-sizing: border-box;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/Countdown/Countdown.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { Portal } from 'react-portal';
3 |
4 | import styles from './Countdown.scss';
5 |
6 | const Countdown = ({ children }) => (
7 |
8 |
11 |
12 | );
13 |
14 | Countdown.propTypes = {
15 | children: PropTypes.node.isRequired,
16 | };
17 |
18 | export default Countdown;
19 |
--------------------------------------------------------------------------------
/src/components/Countdown/Countdown.scss:
--------------------------------------------------------------------------------
1 | .Countdown {
2 | position: fixed;
3 | top: 0;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 | background-color: rgba(0, 0, 0, 0.75);
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | }
12 |
13 | .CountdownText {
14 | color: #ffffff;
15 | font-size: 64px;
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/FormInput/FormInput.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import classNames from 'classnames';
3 |
4 | import formStyles from 'styles/base/forms.scss';
5 |
6 | // Disable reason: React refs dont work on functional components
7 | // https://facebook.github.io/react/docs/refs-and-the-dom.html#the-ref-callback-attribute
8 | //
9 | // eslint-disable-next-line react/prefer-stateless-function
10 | class FormInput extends Component {
11 | handleRef = refHandler => (node) => {
12 | if (refHandler) {
13 | this.node = node;
14 | refHandler(node);
15 | }
16 | }
17 |
18 | render() {
19 | const { base, isGrouped, full, inputRef, ...rest } = this.props;
20 | const inputStyles = classNames(formStyles.input, {
21 | [formStyles['input--base']]: base,
22 | [formStyles['input--group__input']]: isGrouped,
23 | [formStyles['input--full']]: full,
24 | });
25 |
26 | return (
27 |
32 | );
33 | }
34 | }
35 |
36 | FormInput.propTypes = {
37 | base: PropTypes.bool,
38 | isGrouped: PropTypes.bool,
39 | full: PropTypes.bool,
40 | inputRef: PropTypes.func,
41 | };
42 |
43 | export default FormInput;
44 |
--------------------------------------------------------------------------------
/src/components/FormInputGroup/FormInputGroup.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import classNames from 'classnames';
3 |
4 | import formStyles from 'styles/base/forms.scss';
5 |
6 | const FormGroup = (props) => {
7 | const inputGroupStyles = classNames(
8 | formStyles.input,
9 | formStyles['input--group'],
10 | formStyles['input--full']
11 | );
12 |
13 | return ;
14 | };
15 |
16 | FormGroup.propTypes = {
17 | children: PropTypes.oneOfType([
18 | PropTypes.arrayOf(PropTypes.node),
19 | PropTypes.node,
20 | ]),
21 | };
22 |
23 | export default FormGroup;
24 |
--------------------------------------------------------------------------------
/src/components/GameLobby/GameLobby.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | import { CountdownManager } from 'containers';
4 | import { Countdown, PlayerCard, InvitePlayerModal } from 'components';
5 |
6 | import styles from './GameLobby.scss';
7 |
8 | const GameLobby = ({
9 | player,
10 | opponent,
11 | inviteLink,
12 | hasOpponent,
13 | countdown,
14 | toggleReady,
15 | friendInviteModal,
16 | playerCardActions: { openFriendInviteModal, closeFriendInviteModal },
17 | }) => (
18 |
19 |
20 | {countdown.canCountdown && (
21 |
22 | {({ time }) => {time}}
23 |
24 | )}
25 |
26 |
27 |
28 |
29 | VS
30 |
31 | {hasOpponent ? (
32 |
33 | ) : (
34 |
39 |
40 |
45 |
46 | }
47 | />
48 | )}
49 |
50 |
51 |
54 |
55 |
56 |
57 | );
58 |
59 | GameLobby.propTypes = {
60 | player: PropTypes.shape({
61 | name: PropTypes.string.isRequired,
62 | ready: PropTypes.bool.isRequired,
63 | }).isRequired,
64 | opponent: PropTypes.shape({
65 | name: PropTypes.string.isRequired,
66 | ready: PropTypes.bool.isRequired,
67 | }).isRequired,
68 | inviteLink: PropTypes.string.isRequired,
69 | countdown: PropTypes.shape({
70 | canCountdown: PropTypes.bool.isRequired,
71 | onFinish: PropTypes.func.isRequired,
72 | }).isRequired,
73 | hasOpponent: PropTypes.bool.isRequired,
74 | toggleReady: PropTypes.func.isRequired,
75 | friendInviteModal: PropTypes.shape({
76 | isOpen: PropTypes.bool.isRequired,
77 | }).isRequired,
78 | playerCardActions: PropTypes.shape({
79 | openFriendInviteModal: PropTypes.func.isRequired,
80 | closeFriendInviteModal: PropTypes.func.isRequired,
81 | }),
82 | };
83 |
84 | export default GameLobby;
85 |
--------------------------------------------------------------------------------
/src/components/GameLobby/GameLobby.scss:
--------------------------------------------------------------------------------
1 | .Lobby {
2 | width: 100%;
3 | height: 100%;
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | }
8 |
9 | .LobbyInnerWrapper {
10 | flex-grow: 1;
11 | }
12 |
13 | .LobbyVersus {
14 | display: flex;
15 | justify-content: space-around;
16 | align-items: center;
17 | margin: 0 auto;
18 | padding: 20px;
19 | height: 70%;
20 | }
21 |
22 | .LobbyReadyWrapper {
23 | width: 100%;
24 | text-align: center;
25 | }
26 |
27 | .LobbyReadyButton {
28 | width: 200px;
29 | height: 45px;
30 | background: orange;
31 | color: #ffffff;
32 | font-size: 1.2em;
33 | border: 0;
34 | border-radius: 4px;
35 | cursor: pointer;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/Hand/Hand.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import classNames from 'classnames';
3 | import { List } from 'immutable';
4 | import { DraggableCard } from 'containers';
5 |
6 | import styles from './Hand.scss';
7 | import cardStyles from '../Card/Card.scss';
8 |
9 | function hasEnoughMana(card, spendableMana) {
10 | return card.mana <= spendableMana;
11 | }
12 |
13 | const Hand = ({ yourTurn, spendableMana, cards }) => {
14 | const cardsLength = cards.size;
15 | const cardList = cards.map((card, index) => {
16 | const cardClasses = classNames(cardStyles.CardYours, {
17 | [cardStyles[`CardTotal-${cardsLength}`]]: cardsLength,
18 | [cardStyles[`CardNumber-${index + 1}-of-${cardsLength}`]]: (typeof index !== 'undefined'),
19 | });
20 | const canDrag = yourTurn && hasEnoughMana(card, spendableMana);
21 |
22 | return (
23 |
30 | );
31 | });
32 |
33 | return (
34 |
35 | { cardList }
36 |
37 | );
38 | };
39 |
40 | Hand.propTypes = {
41 | cards: PropTypes.instanceOf(List),
42 | yourTurn: PropTypes.bool.isRequired,
43 | spendableMana: PropTypes.number.isRequired,
44 | };
45 |
46 | export default Hand;
47 |
--------------------------------------------------------------------------------
/src/components/Hand/Hand.scss:
--------------------------------------------------------------------------------
1 | .Hand {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | width: 65%;
6 | margin: 0 auto;
7 | color: #ffffff;
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/Hero/Hero.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const styles = {
4 | width: '150px',
5 | height: '75px',
6 | backgroundColor: 'red',
7 | borderRadius: '5px',
8 | };
9 |
10 | const Hero = ({ health }) => (
11 |
12 | { health }
13 |
14 | );
15 |
16 | Hero.propTypes = {
17 | health: PropTypes.number.isRequired,
18 | };
19 |
20 | export default Hero;
21 |
--------------------------------------------------------------------------------
/src/components/InvitePlayerModal/InvitePlayerModal.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | import { Modal, FormInputGroup, FormInput } from 'components';
4 |
5 | class InvitePlayerModal extends Component {
6 | select = () => {
7 | const inviteLinkInput = this.inviteLinkInput;
8 |
9 | inviteLinkInput.focus();
10 | inviteLinkInput.setSelectionRange(0, inviteLinkInput.value.length);
11 | }
12 |
13 | copy = () => {
14 | document.execCommand('copy');
15 | }
16 |
17 | selectAndCopy = () => {
18 | this.select();
19 | this.copy();
20 | }
21 |
22 | render() {
23 | const { inviteLink, isOpen, onClose } = this.props;
24 |
25 | return (
26 |
32 |
33 | Invite a player
34 |
35 |
36 | { this.inviteLinkInput = input; }}
39 | value={inviteLink}
40 | onClick={this.select}
41 | full isGrouped readOnly
42 | />
43 |
46 |
47 |
48 | );
49 | }
50 | }
51 |
52 | InvitePlayerModal.propTypes = {
53 | inviteLink: PropTypes.string.isRequired,
54 | isOpen: PropTypes.bool.isRequired,
55 | onClose: PropTypes.func.isRequired,
56 | };
57 |
58 | export default InvitePlayerModal;
59 |
--------------------------------------------------------------------------------
/src/components/Minion/Minion.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import classNames from 'classnames';
3 | import { CardModel } from 'redux/modules/card';
4 |
5 | import styles from './Minion.scss';
6 |
7 | const Minion = ({ card: { attack, defense, portrait }, exhausted }) => {
8 | const minionStyles = classNames(styles.Minion, {
9 | [styles.MinionSleeping]: exhausted,
10 | });
11 |
12 | return (
13 |
14 |
15 | { attack || 0 }
16 |
17 |
18 | { defense || 0 }
19 |
20 |
21 | );
22 | };
23 |
24 | Minion.propTypes = {
25 | card: PropTypes.instanceOf(CardModel).isRequired,
26 | exhausted: PropTypes.bool,
27 | };
28 |
29 | export default Minion;
30 |
--------------------------------------------------------------------------------
/src/components/Minion/Minion.scss:
--------------------------------------------------------------------------------
1 | @import '../shared/Stats';
2 |
3 | .Minion {
4 | position: relative;
5 | width: 12vh;
6 | height: 100%;
7 | background: #ffffff;
8 | border: 0.6vh solid #616065;
9 | border-radius: 100%;
10 | background-size: cover;
11 | background-position: center;
12 | margin: 0px 5px;
13 | box-shadow: 0px 0px 5px rgba(0,0,0,0.5);
14 | }
15 |
16 | $stats-distance: 0.5vh;
17 | $stats-padding: 1vh 1.5vh;
18 |
19 | .MinionStat {
20 | font-size: 2.5vh;
21 | }
22 |
23 | .MinionAttack {
24 | @extend %stats;
25 | padding: $stats-padding;
26 | background: orange;
27 | bottom: -$stats-distance;
28 | left: -$stats-distance;
29 | }
30 |
31 | .MinionDefense {
32 | @extend %stats;
33 | padding: $stats-padding;
34 | background: #bc3328;
35 | bottom: -$stats-distance;
36 | right: -$stats-distance;
37 | }
38 |
39 | .MinionSleeping {
40 | &:before {
41 | content: 'z';
42 | position: absolute;
43 | top: 5px;
44 | right: 10px;
45 | }
46 | &:after {
47 | content: 'z z';
48 | position: absolute;
49 | top: -15px;
50 | right: 7px;
51 | width: 5px;
52 | line-height: 0.7;
53 | text-indent: 5px;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/MinionsOnBoard/MinionsOnBoard.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import styles from './MinionsOnBoard.scss';
3 |
4 | const MinionsOnBoard = ({ children }) => (
5 |
6 | { children }
7 |
8 | );
9 |
10 | MinionsOnBoard.propTypes = {
11 | children: PropTypes.node.isRequired,
12 | };
13 |
14 | export default MinionsOnBoard;
15 |
--------------------------------------------------------------------------------
/src/components/MinionsOnBoard/MinionsOnBoard.scss:
--------------------------------------------------------------------------------
1 | .MinionsOnBoard {
2 | width: 100%;
3 | height: 100%;
4 | display: flex;
5 | justify-content: center;
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/Modal/Modal.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import ReactModal from 'react-modal';
3 |
4 | import styles from './Modal.scss';
5 |
6 | const Modal = ({ children, size = '90%', ...rest }) => (
7 |
17 | { children }
18 |
19 | );
20 |
21 | Modal.propTypes = {
22 | children: PropTypes.node.isRequired,
23 | size: PropTypes.string,
24 | maxWidth: PropTypes.string,
25 | };
26 |
27 | export default Modal;
28 |
--------------------------------------------------------------------------------
/src/components/Modal/Modal.scss:
--------------------------------------------------------------------------------
1 | .ModalOverlay {
2 | align-items: center;
3 | background-color: rgba(0, 0, 0, 0.75);
4 | bottom: 0;
5 | display: flex;
6 | justify-content: center;
7 | left: 0;
8 | position: fixed;
9 | right: 0;
10 | top: 0;
11 | }
12 |
13 | .Modal {
14 | background: #fff;
15 | border-radius: 4px;
16 | border: 1px solid #ccc;
17 | max-width: 1000px;
18 | outline: none;
19 | padding: 20px;
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/Opponent/Opponent.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { List } from 'immutable';
3 | import { TargetableHero, TargetableMinion } from 'containers';
4 | import { OpponentHand, MinionsOnBoard, BoardSide } from 'components';
5 |
6 | import styles from './Opponent.scss';
7 |
8 | const Opponent = ({ name, character, handCount, board, actions }) => {
9 | const { mana, health } = character;
10 |
11 | const minions = board.map((card, index) => (
12 |
13 | ));
14 |
15 | return (
16 |
17 |
18 | { name || 'Unnamed' } - Mana: { mana.spendableMana }/{ mana.max } and Health: { health }
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | { minions }
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | Opponent.propTypes = {
34 | name: PropTypes.string,
35 | character: PropTypes.shape({
36 | health: PropTypes.number.isRequired,
37 | mana: PropTypes.shape({
38 | max: PropTypes.number.isRequired,
39 | spendableMana: PropTypes.number.isRequired,
40 | }),
41 | }),
42 | handCount: PropTypes.number,
43 | deckCount: PropTypes.number,
44 | board: PropTypes.instanceOf(List),
45 | actions: PropTypes.shape({
46 | playCardWithCost: PropTypes.func.isRequired,
47 | drawCard: PropTypes.func.isRequired,
48 | hitFace: PropTypes.func.isRequired,
49 | attackMinion: PropTypes.func.isRequired,
50 | }).isRequired,
51 | };
52 |
53 | export default Opponent;
54 |
--------------------------------------------------------------------------------
/src/components/Opponent/Opponent.scss:
--------------------------------------------------------------------------------
1 | .Opponent {
2 | position: relative;
3 | width: 100%;
4 | height: 100%;
5 | }
6 |
7 | .OpponentHandWrapper {
8 | height: 70%;
9 | }
10 |
11 | .OpponentHand {
12 | display: flex;
13 | align-items: center;
14 | justify-content: center;
15 | width: 65%;
16 | margin: 0 auto;
17 | color: #ffffff;
18 | }
19 |
20 | .OpponentName {
21 | position: absolute;
22 | top: 20%;
23 | left: 0;
24 | display: inline;
25 | color: #ffffff;
26 | background: rgba(0,0,0,0.75);
27 | padding: 10px 15px;
28 | border-radius: 0 5px 5px 0;
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/OpponentHand/OpponentHand.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { CardBack } from 'components';
3 |
4 | import styles from './../Hand/Hand.scss';
5 | import cardStyles from './../Card/Card.scss';
6 |
7 | const OpponentHand = ({ handCount }) => {
8 | const margin = handCount * 25;
9 | const cardList = Array.from(new Array(handCount), (_, index) => (
10 |
11 | ));
12 |
13 | return (
14 |
15 | { cardList }
16 |
17 | );
18 | };
19 |
20 | OpponentHand.propTypes = {
21 | handCount: PropTypes.number.isRequired,
22 | };
23 |
24 | export default OpponentHand;
25 |
--------------------------------------------------------------------------------
/src/components/Player/Player.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { List } from 'immutable';
3 | import { TargetableHero, BoardSideDropTarget, CustomDragLayer } from 'containers';
4 | import { Hand, BoardSide } from 'components';
5 |
6 | import styles from './Player.scss';
7 |
8 | const Player = ({ name, character, hand, board, exhaustedMinionIds, yourTurn, actions }) => {
9 | const { mana, health } = character;
10 | const isBoardFull = board.size >= 7;
11 |
12 | return (
13 |
14 |
15 |
16 |
23 |
24 |
25 | { name || 'Unnamed' } - Mana: { mana.spendableMana }/{ mana.max } and Health: { health }
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | Player.propTypes = {
36 | name: PropTypes.string,
37 | character: PropTypes.shape({
38 | health: PropTypes.number.isRequired,
39 | mana: PropTypes.shape({
40 | max: PropTypes.number.isRequired,
41 | spendableMana: PropTypes.number.isRequired,
42 | }),
43 | }),
44 | hand: PropTypes.instanceOf(List),
45 | deck: PropTypes.arrayOf(),
46 | board: PropTypes.instanceOf(List),
47 | exhaustedMinionIds: PropTypes.instanceOf(List),
48 | yourTurn: PropTypes.bool.isRequired,
49 | actions: PropTypes.shape({
50 | playCardWithCost: PropTypes.func.isRequired,
51 | drawCard: PropTypes.func.isRequired,
52 | hitFace: PropTypes.func.isRequired,
53 | }).isRequired,
54 | };
55 |
56 | export default Player;
57 |
--------------------------------------------------------------------------------
/src/components/Player/Player.scss:
--------------------------------------------------------------------------------
1 | .Player {
2 | position: relative;
3 | height: 100%;
4 | }
5 |
6 | .PlayerHandWrapper {
7 | width: 100%;
8 | height: 70%;
9 | }
10 |
11 | .PlayerName {
12 | position: absolute;
13 | bottom: 20%;
14 | color: #ffffff;
15 | background: rgba(0,0,0,0.75);
16 | padding: 10px 15px;
17 | border-radius: 0 5px 5px 0;
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/PlayerCard/PlayerCard.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import styles from './PlayerCard.scss';
3 |
4 | const PlayerCard = ({
5 | playerName,
6 | ready = false,
7 | button = null,
8 | showReady = true,
9 | }) => (
10 |
11 |
12 | { playerName }
13 | { showReady && (ready ? 'Ready' : 'Not ready') }
14 | { button && button }
15 |
16 | );
17 |
18 | PlayerCard.propTypes = {
19 | playerName: PropTypes.string.isRequired,
20 | ready: PropTypes.bool,
21 | button: PropTypes.node,
22 | showReady: PropTypes.bool,
23 | };
24 |
25 | export default PlayerCard;
26 |
--------------------------------------------------------------------------------
/src/components/PlayerCard/PlayerCard.scss:
--------------------------------------------------------------------------------
1 | .PlayerCard {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: center;
5 | align-content: center;
6 | text-align: center;
7 | width: 30%;
8 | height: 300px;
9 | background: #ebebeb;
10 | }
11 |
12 | .PlayerCardAvatar {
13 | width: 100px;
14 | height: 100px;
15 | background: orange;
16 | border-radius: 50%;
17 | margin: 0 auto;
18 | }
19 |
20 | .PlayerCardName {
21 | font-size: 2em;
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/PlayerSide/PlayerSide.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | import styles from './PlayerSide.scss';
4 |
5 | const PlayerSide = ({ children }) => (
6 |
7 | { children }
8 |
9 | );
10 |
11 | PlayerSide.propTypes = {
12 | children: PropTypes.element.isRequired,
13 | };
14 |
15 | export default PlayerSide;
16 |
--------------------------------------------------------------------------------
/src/components/PlayerSide/PlayerSide.scss:
--------------------------------------------------------------------------------
1 | .PlayerSide {
2 | width: 100%;
3 | height: 48%;
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | export BoardSide from './BoardSide/BoardSide';
2 | export Card from './Card/Card';
3 | export CardBack from './CardBack/CardBack';
4 | export Countdown from './Countdown/Countdown';
5 | export GameLobby from './GameLobby/GameLobby';
6 | export Hand from './Hand/Hand';
7 | export Hero from './Hero/Hero';
8 | export InvitePlayerModal from './InvitePlayerModal/InvitePlayerModal';
9 | export Minion from './Minion/Minion';
10 | export MinionsOnBoard from './MinionsOnBoard/MinionsOnBoard';
11 | export Modal from './Modal/Modal';
12 | export Opponent from './Opponent/Opponent';
13 | export OpponentHand from './OpponentHand/OpponentHand';
14 | export Player from './Player/Player';
15 | export PlayerCard from './PlayerCard/PlayerCard';
16 | export PlayerSide from './PlayerSide/PlayerSide';
17 |
18 | // Forms
19 | export FormInput from './FormInput/FormInput';
20 | export FormInputGroup from './FormInputGroup/FormInputGroup';
21 |
--------------------------------------------------------------------------------
/src/components/shared/Stats.scss:
--------------------------------------------------------------------------------
1 | $stat-mana-color: #4681ee;
2 | $stat-attack-color: orange;
3 | $stat-defense-color: #bc3328;
4 |
5 | %stats {
6 | display: inline-block;
7 | position: absolute;
8 | border-radius: 100%;
9 | color: #ffffff;
10 | box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.3);
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/shared/styles.scss:
--------------------------------------------------------------------------------
1 | .fullSize {
2 | width: 100%;
3 | height: 100%;
4 | }
5 |
6 | .fullHeight {
7 | height: 100%;
8 | }
9 |
--------------------------------------------------------------------------------
/src/containers/App/App.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import sharedStyles from 'components/shared/styles.scss';
3 |
4 | const App = ({ children }) => (
5 |
6 | { children }
7 |
8 | );
9 |
10 | App.propTypes = {
11 | children: PropTypes.node,
12 | };
13 |
14 | export default App;
15 |
--------------------------------------------------------------------------------
/src/containers/Board/Board.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { compose, bindActionCreators } from 'redux';
3 | import { connect } from 'react-redux';
4 | import { DragDropContext as dragDropContext } from 'react-dnd';
5 | import HTML5Backend from 'react-dnd-html5-backend';
6 |
7 | import { boardSelector } from 'redux/modules/board';
8 | import { endTurn } from 'redux/modules/yourTurn';
9 | import { drawCard } from 'redux/modules/deck';
10 | import { playCardWithCost } from 'redux/modules/hand';
11 | import { hitFace, attackMinion } from 'redux/modules/minion';
12 | import { Player, PlayerSide, Opponent } from 'components';
13 |
14 | import styles from './Board.scss';
15 |
16 | export const Board = ({ yourTurn, player, opponent, actions }) => (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 |
28 | Board.propTypes = {
29 | yourTurn: PropTypes.bool.isRequired,
30 | player: PropTypes.shape({
31 | name: PropTypes.string.isRequired,
32 | }),
33 | opponent: PropTypes.shape({
34 | name: PropTypes.string.isRequired,
35 | }),
36 | actions: PropTypes.shape({
37 | playCardWithCost: PropTypes.func.isRequired,
38 | drawCard: PropTypes.func.isRequired,
39 | hitFace: PropTypes.func.isRequired,
40 | attackMinion: PropTypes.func.isRequired,
41 | }),
42 | };
43 |
44 | const mapStateToProps = state => ({
45 | yourTurn: state.yourTurn,
46 | player: {
47 | ...state.player,
48 | board: boardSelector(state, 'player'),
49 | exhaustedMinionIds: state.player.board.exhaustedMinionIds,
50 | },
51 | opponent: {
52 | ...state.opponent,
53 | board: boardSelector(state, 'opponent'),
54 | exhaustedMinionIds: state.player.board.exhaustedMinionIds,
55 | },
56 | });
57 |
58 | const mapDispatchToProps = dispatch => ({
59 | actions: bindActionCreators({
60 | playCardWithCost,
61 | drawCard,
62 | hitFace,
63 | attackMinion,
64 | endTurn,
65 | }, dispatch),
66 | });
67 |
68 | export default compose(
69 | dragDropContext(HTML5Backend),
70 | connect(mapStateToProps, mapDispatchToProps),
71 | )(Board);
72 |
--------------------------------------------------------------------------------
/src/containers/Board/Board.scss:
--------------------------------------------------------------------------------
1 | .Board {
2 | display: flex;
3 | flex-flow: row wrap;
4 | align-items: flex-end;
5 | background: LightSteelBlue;
6 | width: 100%;
7 | height: 100%;
8 | overflow: hidden;
9 |
10 | &:first-child {
11 | align-self: flex-start;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/containers/BoardSideDropTarget/BoardSideDropTarget.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { findDOMNode } from 'react-dom';
3 | import { DropTarget as dropTarget } from 'react-dnd';
4 | import { List } from 'immutable';
5 | import { MinionDropTarget } from 'containers';
6 | import { MinionsOnBoard } from 'components';
7 |
8 | import sharedStyles from 'components/shared/styles.scss';
9 |
10 | export class BoardSideDropTarget extends Component {
11 | static propTypes = {
12 | board: PropTypes.instanceOf(List),
13 | exhaustedMinionIds: PropTypes.instanceOf(List),
14 | connectDropTarget: PropTypes.func.isRequired,
15 | yourTurn: PropTypes.bool.isRequired,
16 | isBoardFull: PropTypes.bool.isRequired,
17 | playCard: PropTypes.func.isRequired,
18 | }
19 |
20 | render() {
21 | const {
22 | yourTurn,
23 | isBoardFull,
24 | board,
25 | exhaustedMinionIds,
26 | connectDropTarget,
27 | playCard,
28 | } = this.props;
29 |
30 | const minions = board.map((card, index) => (
31 |
41 | ));
42 |
43 | return connectDropTarget(
44 |
45 |
46 | { minions }
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | const boardTarget = {
54 | canDrop(props) {
55 | return !props.isBoardFull;
56 | },
57 | drop(props, monitor, component) {
58 | const card = monitor.getItem();
59 | const droppedOnBoard = monitor.isOver({ shallow: true });
60 |
61 | if (!droppedOnBoard) return undefined;
62 |
63 | const componentRect = findDOMNode(component).getBoundingClientRect();
64 | const boardMiddleX = componentRect.width / 2;
65 | const mousePosition = monitor.getClientOffset();
66 | const mousePositionInMinion = mousePosition.x - componentRect.left;
67 |
68 | // Mouse is on the right side of the minion
69 | if (mousePositionInMinion > boardMiddleX) {
70 | return props.playCard({ ...card, boardIndex: props.board.size });
71 | }
72 |
73 | return props.playCard(card);
74 | },
75 | };
76 |
77 | function collect(connect) {
78 | return { connectDropTarget: connect.dropTarget() };
79 | }
80 |
81 | export default dropTarget('CARD', boardTarget, collect)(BoardSideDropTarget);
82 |
--------------------------------------------------------------------------------
/src/containers/CountdownManager/CountdownManager.js:
--------------------------------------------------------------------------------
1 | import { Component, PropTypes } from 'react';
2 |
3 | export default class CountdownManager extends Component {
4 | static propTypes = {
5 | startTime: PropTypes.number.isRequired,
6 | onFinish: PropTypes.func.isRequired,
7 | children: PropTypes.func.isRequired,
8 | }
9 |
10 | state = {
11 | started: false,
12 | time: this.props.startTime || 0,
13 | };
14 |
15 | componentDidMount() {
16 | this.startCountdown();
17 | }
18 |
19 | componentWillUnmount() {
20 | clearInterval(this.countdownInterval);
21 | }
22 |
23 | startCountdown = () => {
24 | // If already started, don't do anything.
25 | if (this.state.started) return;
26 |
27 | this.setState(() => ({ started: true }));
28 | this.countdownInterval = setInterval(this.countDown, 1000);
29 | }
30 |
31 | countDown = () => {
32 | this.setState(() => ({ time: this.state.time - 1 }));
33 |
34 | if (this.state.time <= 0) {
35 | this.props.onFinish();
36 | clearInterval(this.countdownInterval);
37 | }
38 | }
39 |
40 | render() {
41 | return this.props.children({
42 | time: this.state.time,
43 | });
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/containers/CustomDragLayer/CustomDragLayer.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { DragLayer as dragLayer } from 'react-dnd';
3 | import { Minion, Card } from 'components';
4 | import { CardModel } from 'redux/modules/card';
5 |
6 | const layerStyles = {
7 | position: 'fixed',
8 | pointerEvents: 'none',
9 | zIndex: 100,
10 | left: 0,
11 | top: 0,
12 | width: '100%',
13 | height: '100%',
14 | };
15 |
16 | function getItemStyles(props) {
17 | const { currentOffset } = props;
18 | if (!currentOffset) {
19 | return {
20 | display: 'none',
21 | };
22 | }
23 |
24 | const { x, y } = currentOffset;
25 | const transform = `translate(${x}px, ${y}px)`;
26 | return {
27 | transform,
28 | WebkitTransform: transform,
29 | };
30 | }
31 |
32 | class CustomDragLayer extends Component {
33 | static propTypes = {
34 | item: PropTypes.shape({
35 | card: PropTypes.instanceOf(CardModel),
36 | }),
37 | itemType: PropTypes.string,
38 | currentOffset: PropTypes.shape({
39 | x: PropTypes.number.isRequired,
40 | y: PropTypes.number.isRequired,
41 | }),
42 | isDragging: PropTypes.bool.isRequired,
43 | }
44 | render() {
45 | const { item, itemType, isDragging } = this.props;
46 |
47 | if (!isDragging) {
48 | return null;
49 | }
50 |
51 | return (
52 |
53 |
54 | { itemType === 'CARD' ?
55 |
56 | : null }
57 | { itemType === 'MINION' ?
58 |
59 |
60 |
61 | : null }
62 |
63 |
64 | );
65 | }
66 | }
67 |
68 | function collect(monitor) {
69 | return {
70 | item: monitor.getItem(),
71 | itemType: monitor.getItemType(),
72 | currentOffset: monitor.getSourceClientOffset(),
73 | isDragging: monitor.isDragging(),
74 | };
75 | }
76 |
77 | export default dragLayer(collect)(CustomDragLayer);
78 |
--------------------------------------------------------------------------------
/src/containers/DraggableCard/DraggableCard.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { getEmptyImage } from 'react-dnd-html5-backend';
3 | import { DragSource as dragSource } from 'react-dnd';
4 | import classNames from 'classnames';
5 | import { CardModel } from 'redux/modules/card';
6 | import { Card } from 'components';
7 |
8 | import styles from 'components/Card/Card.scss';
9 |
10 | export class DraggableCard extends Component {
11 | static propTypes = {
12 | connectDragSource: PropTypes.func.isRequired,
13 | connectDragPreview: PropTypes.func.isRequired,
14 | index: PropTypes.number.isRequired,
15 | card: PropTypes.instanceOf(CardModel).isRequired,
16 | className: PropTypes.string,
17 | hoverable: PropTypes.bool,
18 | isDragging: PropTypes.bool.isRequired,
19 | canDrag: PropTypes.bool.isRequired,
20 | }
21 |
22 | componentDidMount() {
23 | const { connectDragPreview } = this.props;
24 | connectDragPreview(getEmptyImage());
25 | }
26 |
27 | render() {
28 | const { connectDragSource, isDragging, canDrag, card, className } = this.props;
29 | const newClass = classNames(className, {
30 | [styles.CardCanDrag]: canDrag,
31 | });
32 |
33 | return connectDragSource(
34 |
35 |
36 |
37 | );
38 | }
39 | }
40 |
41 | const cardSource = {
42 | canDrag(props) {
43 | return props.canDrag;
44 | },
45 | beginDrag(props) {
46 | return {
47 | card: props.card,
48 | handIndex: props.index,
49 | source: 'PLAYER',
50 | target: 'PLAYER',
51 | };
52 | },
53 | };
54 |
55 | function collect(connect, monitor) {
56 | return {
57 | connectDragSource: connect.dragSource(),
58 | connectDragPreview: connect.dragPreview(),
59 | isDragging: monitor.isDragging(),
60 | };
61 | }
62 |
63 | export default dragSource('CARD', cardSource, collect)(DraggableCard);
64 |
--------------------------------------------------------------------------------
/src/containers/DraggableMinion/DraggableMinion.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { getEmptyImage } from 'react-dnd-html5-backend';
3 | import { DragSource as dragSource } from 'react-dnd';
4 | import { CardModel } from 'redux/modules/card';
5 | import { Minion } from 'components';
6 |
7 | import sharedStyles from 'components/shared/styles.scss';
8 |
9 | export class DraggableMinion extends Component {
10 | static propTypes = {
11 | connectDragSource: PropTypes.func.isRequired,
12 | connectDragPreview: PropTypes.func.isRequired,
13 | yourTurn: PropTypes.bool.isRequired,
14 | isDragging: PropTypes.bool.isRequired,
15 | card: PropTypes.instanceOf(CardModel).isRequired,
16 | exhausted: PropTypes.bool.isRequired,
17 | }
18 |
19 | componentDidMount() {
20 | const { connectDragPreview } = this.props;
21 | connectDragPreview(getEmptyImage());
22 | }
23 |
24 | render() {
25 | const { connectDragSource, isDragging, card, exhausted } = this.props;
26 |
27 | return connectDragSource(
28 |
29 |
30 |
31 | );
32 | }
33 | }
34 |
35 | const minionSource = {
36 | beginDrag(props) {
37 | return {
38 | card: props.card,
39 | };
40 | },
41 |
42 | canDrag(props) {
43 | if (!props.yourTurn) return false;
44 | if (props.exhausted) return false;
45 | if (props.card.attack <= 0) return false;
46 | return true;
47 | },
48 | };
49 |
50 | function collect(connect, monitor) {
51 | return {
52 | connectDragSource: connect.dragSource(),
53 | connectDragPreview: connect.dragPreview(),
54 | isDragging: monitor.isDragging(),
55 | };
56 | }
57 |
58 | export default dragSource('MINION', minionSource, collect)(DraggableMinion);
59 |
--------------------------------------------------------------------------------
/src/containers/MinionDropTarget/MinionDropTarget.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { findDOMNode } from 'react-dom';
3 | import { DropTarget as dropTarget } from 'react-dnd';
4 | import { CardModel } from 'redux/modules/card';
5 | import { DraggableMinion } from 'containers';
6 |
7 | import sharedStyles from 'components/shared/styles.scss';
8 |
9 | export class MinionDropTarget extends Component {
10 | static propTypes = {
11 | connectDropTarget: PropTypes.func.isRequired,
12 | boardSize: PropTypes.number.isRequired,
13 | index: PropTypes.number.isRequired,
14 | yourTurn: PropTypes.bool.isRequired,
15 | canDrop: PropTypes.bool.isRequired,
16 | card: PropTypes.instanceOf(CardModel).isRequired,
17 | exhausted: PropTypes.bool.isRequired,
18 | playCard: PropTypes.func.isRequired,
19 | }
20 |
21 | render() {
22 | const { yourTurn, connectDropTarget, card, exhausted } = this.props;
23 |
24 | return connectDropTarget(
25 |
26 |
27 |
28 | );
29 | }
30 | }
31 |
32 | const cardTarget = {
33 | canDrop(props) {
34 | return props.canDrop;
35 | },
36 |
37 | drop(props, monitor, component) {
38 | const minionIndex = props.index;
39 | const card = monitor.getItem();
40 |
41 | const componentRect = findDOMNode(component).getBoundingClientRect();
42 | const minionMiddleX = componentRect.width / 2;
43 | const mousePosition = monitor.getClientOffset();
44 | const mousePositionInMinion = mousePosition.x - componentRect.left;
45 |
46 | // Mouse is on the right side of the minion
47 | if (mousePositionInMinion > minionMiddleX) {
48 | return props.playCard({ ...card, boardIndex: minionIndex + 1 });
49 | }
50 |
51 | return props.playCard({ ...card, boardIndex: minionIndex });
52 | },
53 | };
54 |
55 | function collect(connect) {
56 | return {
57 | connectDropTarget: connect.dropTarget(),
58 | };
59 | }
60 |
61 | export default dropTarget('CARD', cardTarget, collect)(MinionDropTarget);
62 |
--------------------------------------------------------------------------------
/src/containers/NewPlayerForm/NewPlayerForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | import { FormInput } from 'components';
4 |
5 | function capitalizeFirstLetter(string) {
6 | return string.charAt(0).toUpperCase() + string.slice(1);
7 | }
8 |
9 | export default class NewPlayerForm extends Component {
10 | static propTypes = {
11 | playerName: PropTypes.string.isRequired,
12 | onSubmit: PropTypes.func.isRequired,
13 | };
14 |
15 | state = { nameInput: '' }
16 |
17 | onChangeHandler = (event) => {
18 | this.setState({ nameInput: event.target.value });
19 | }
20 |
21 | onSubmitHandler = (event) => {
22 | event.preventDefault();
23 | this.props.onSubmit(capitalizeFirstLetter(this.state.nameInput));
24 | this.clearForm();
25 | }
26 |
27 | clearForm = () => {
28 | this.setState({ nameInput: '' });
29 | }
30 |
31 | render() {
32 | return (
33 |
43 | );
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/containers/Root/Root.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Provider } from 'react-redux';
3 | import { Router, Route, IndexRoute, hashHistory } from 'react-router';
4 |
5 | import { App, SocketProvider } from 'containers';
6 | import { GameLobbyScreen, GameScreen, StartScreen } from 'views';
7 | import { fetchNewGame, joinGame } from 'redux/modules/currentGame';
8 | import sharedStyles from 'components/shared/styles.scss';
9 |
10 | export default class Root extends Component {
11 | static propTypes = {
12 | store: PropTypes.shape({
13 | dispatch: PropTypes.func.isRequired,
14 | getState: PropTypes.func.isRequired,
15 | }).isRequired,
16 | socket: PropTypes.shape({
17 | on: PropTypes.func.isRequired,
18 | }).isRequired,
19 | };
20 |
21 | requireName = (nextState, replace) => {
22 | const { name } = this.props.store.getState().player;
23 |
24 | if (!name) {
25 | replace({
26 | pathname: '/',
27 | query: { ref: nextState.location.pathname },
28 | });
29 | }
30 | };
31 |
32 | redirectToLobby = (replace, id) => {
33 | replace(`/game/${id}/lobby`);
34 | };
35 |
36 | redirectIfNoGameId = (nextState, replace) => {
37 | const { gameId } = this.props.store.getState().currentGame;
38 |
39 | if (!gameId) {
40 | replace('/');
41 | }
42 | };
43 |
44 | createGameAndRedirect = (nextState, replace, callback) => {
45 | this.props.store
46 | .dispatch(fetchNewGame(true))
47 | .then((gameId) => {
48 | this.redirectToLobby(replace, gameId);
49 | callback();
50 | })
51 | .catch((e) => {
52 | console.log(e);
53 | replace('/');
54 | });
55 | };
56 |
57 | joinGameAndRedirect = (nextState, replace) => {
58 | const { id } = nextState.params;
59 |
60 | this.props.store.dispatch(joinGame(id));
61 |
62 | this.redirectToLobby(replace, id);
63 | };
64 |
65 | render() {
66 | const { store, socket } = this.props;
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | );
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/containers/SocketProvider/SocketProvider.js:
--------------------------------------------------------------------------------
1 | import { Component, PropTypes, Children } from 'react';
2 |
3 | export default class SocketProvider extends Component {
4 | static propTypes = {
5 | children: PropTypes.node.isRequired,
6 | socket: PropTypes.shape({
7 | on: PropTypes.func.isRequired,
8 | }).isRequired,
9 | }
10 |
11 | static childContextTypes = {
12 | socket: PropTypes.object.isRequired,
13 | }
14 |
15 | getChildContext() {
16 | const { socket } = this.props;
17 | return { socket };
18 | }
19 |
20 | render() {
21 | return Children.only(this.props.children);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/containers/TargetableHero/TargetableHero.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { DropTarget as dropTarget } from 'react-dnd';
3 | import { Hero } from 'components';
4 |
5 | export class TargetableHero extends Component {
6 | static propTypes = {
7 | connectDropTarget: PropTypes.func.isRequired,
8 | ownedBy: PropTypes.oneOf(['PLAYER', 'OPPONENT']).isRequired,
9 | health: PropTypes.number.isRequired,
10 | }
11 |
12 | render() {
13 | const { connectDropTarget, health } = this.props;
14 |
15 | return connectDropTarget(
16 |
17 |
18 |
19 | );
20 | }
21 | }
22 |
23 | const heroTarget = {
24 | drop(props, monitor) {
25 | const minion = monitor.getItem().card;
26 |
27 | props.hitFace({
28 | source: 'PLAYER',
29 | sourceMinionId: minion.id,
30 | target: props.ownedBy,
31 | damage: minion.attack,
32 | });
33 | },
34 | };
35 |
36 | function collect(connect) {
37 | return { connectDropTarget: connect.dropTarget() };
38 | }
39 |
40 | export default dropTarget('MINION', heroTarget, collect)(TargetableHero);
41 |
--------------------------------------------------------------------------------
/src/containers/TargetableMinion/TargetableMinion.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { DropTarget as dropTarget } from 'react-dnd';
3 | import { CardModel } from 'redux/modules/card';
4 |
5 | import { Minion } from 'components';
6 |
7 | export class TargetableMinion extends Component {
8 | static propTypes = {
9 | connectDropTarget: PropTypes.func.isRequired,
10 | index: PropTypes.number.isRequired,
11 | card: PropTypes.instanceOf(CardModel).isRequired,
12 | attackMinion: PropTypes.func.isRequired,
13 | }
14 |
15 | render() {
16 | const { connectDropTarget, card } = this.props;
17 |
18 | return connectDropTarget(
19 |
20 |
21 |
22 | );
23 | }
24 | }
25 |
26 | const minionTarget = {
27 | drop(props, monitor) {
28 | const sourceMinion = monitor.getItem().card;
29 |
30 | props.attackMinion({
31 | sourceMinion,
32 | source: 'PLAYER',
33 | targetMinion: props.card,
34 | target: 'OPPONENT',
35 | });
36 | },
37 | };
38 |
39 | function collect(connect) {
40 | return { connectDropTarget: connect.dropTarget() };
41 | }
42 |
43 | export default dropTarget('MINION', minionTarget, collect)(TargetableMinion);
44 |
--------------------------------------------------------------------------------
/src/containers/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-named-as-default */
2 | export App from './App/App';
3 | export Board from './Board/Board';
4 | export BoardSideDropTarget from './BoardSideDropTarget/BoardSideDropTarget';
5 | export CountdownManager from './CountdownManager/CountdownManager';
6 | export CustomDragLayer from './CustomDragLayer/CustomDragLayer';
7 | export DraggableCard from './DraggableCard/DraggableCard';
8 | export DraggableMinion from './DraggableMinion/DraggableMinion';
9 | export MinionDropTarget from './MinionDropTarget/MinionDropTarget';
10 | export NewPlayerForm from './NewPlayerForm/NewPlayerForm';
11 | export Root from './Root/Root';
12 | export SocketProvider from './SocketProvider/SocketProvider';
13 | export TargetableHero from './TargetableHero/TargetableHero';
14 | export TargetableMinion from './TargetableMinion/TargetableMinion';
15 |
--------------------------------------------------------------------------------
/src/helpers/getDisplayName.js:
--------------------------------------------------------------------------------
1 | export default function getDisplayName(WrappedComponent) {
2 | return WrappedComponent.displayName || WrappedComponent.name || 'Component';
3 | }
4 |
--------------------------------------------------------------------------------
/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export getDisplayName from './getDisplayName';
2 |
--------------------------------------------------------------------------------
/src/hoc/index.js:
--------------------------------------------------------------------------------
1 | export withSocket from './withSocket';
2 |
--------------------------------------------------------------------------------
/src/hoc/withSocket.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { getDisplayName } from 'helpers';
3 |
4 | const withSocket = (WrappedComponent) => {
5 | const withSocketDisplayName = `withSocket(${getDisplayName(WrappedComponent)})`;
6 |
7 | const WithSocket = (props, { socket }) => (
8 |
9 | );
10 |
11 | WithSocket.displayName = withSocketDisplayName;
12 | WithSocket.contextTypes = {
13 | socket: PropTypes.object.isRequired,
14 | };
15 |
16 | return WithSocket;
17 | };
18 |
19 | export default withSocket;
20 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Card game
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/redux/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, compose, applyMiddleware } from 'redux';
2 | import thunk from 'redux-thunk';
3 |
4 | import emitToOpponent from 'redux/middlewares/emitToOpponent';
5 | import persistPlayerName from 'redux/middlewares/persistPlayerName';
6 | import rootReducer from 'redux/modules/rootReducer';
7 |
8 | /* eslint-disable no-underscore-dangle */
9 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
10 | /* eslint-enable */
11 |
12 | const enhancer = socket =>
13 | composeEnhancers(applyMiddleware(thunk, emitToOpponent(socket), persistPlayerName));
14 |
15 | export default function configureStore(initialState, socket) {
16 | const store = createStore(rootReducer, initialState, enhancer(socket));
17 |
18 | if (module.hot) {
19 | module.hot.accept(
20 | './modules/rootReducer',
21 | () => store.replaceReducer(require('./modules/rootReducer').default) // eslint-disable-line global-require
22 | );
23 | }
24 |
25 | return store;
26 | }
27 |
--------------------------------------------------------------------------------
/src/redux/middlewares/emitToOpponent.js:
--------------------------------------------------------------------------------
1 | export default function curryEmitToOpponent(socket) {
2 | return store => next => (action) => {
3 | const { gameId, hasOpponent } = store.getState().currentGame;
4 |
5 | if (!action.fromServer && gameId && hasOpponent) {
6 | socket.emit('action', { gameId, action });
7 | }
8 |
9 | next(action);
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/src/redux/middlewares/persistPlayerName.js:
--------------------------------------------------------------------------------
1 | const persistPlayerName = store => next => (action) => {
2 | const previousPlayerName = store.getState().player.name;
3 |
4 | // Call the action
5 | next(action);
6 |
7 | const newPlayerName = store.getState().player.name;
8 |
9 | if (previousPlayerName !== newPlayerName) {
10 | console.log('Changed local storage');
11 | localStorage.setItem('reduxPlayerName', newPlayerName);
12 | }
13 | };
14 |
15 | export default persistPlayerName;
16 |
--------------------------------------------------------------------------------
/src/redux/modules/board.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import { Record, List } from 'immutable';
3 | import { END_TURN } from './yourTurn';
4 | import { PLAY_CARD } from './hand';
5 | import { EXHAUST_MINION, KILL_MINION } from './minion';
6 |
7 | function minionIds(state = new List(), action) {
8 | switch (action.type) {
9 | case PLAY_CARD:
10 | return state.insert(action.boardIndex, action.card.id);
11 | case KILL_MINION:
12 | return state.filter(id => id !== action.minionId);
13 | default:
14 | return state;
15 | }
16 | }
17 |
18 | function exhaustedMinionIds(state = new List(), action) {
19 | switch (action.type) {
20 | case PLAY_CARD:
21 | return state.push(action.card.id);
22 | case EXHAUST_MINION:
23 | return state.push(action.minionId);
24 | case END_TURN:
25 | return state.clear();
26 | default:
27 | return state;
28 | }
29 | }
30 |
31 | // const MAX_CARDS_ON_BOARD = 7;
32 | const initialState = new Record({
33 | minionIds: minionIds(undefined, {}),
34 | exhaustedMinionIds: exhaustedMinionIds(undefined, {}),
35 | });
36 | export default function boardReducer(state = initialState(), action) {
37 | switch (action.type) {
38 | case PLAY_CARD:
39 | return state.update('minionIds', minionIdsState => (
40 | minionIds(minionIdsState, action)
41 | )).update('exhaustedMinionIds', exhaustedState => (
42 | exhaustedMinionIds(exhaustedState, action)
43 | ));
44 | case KILL_MINION:
45 | return state.update('minionIds', minionIdsState => minionIds(minionIdsState, action));
46 | case EXHAUST_MINION:
47 | case END_TURN:
48 | return state.update('exhaustedMinionIds', exhaustedState => (
49 | exhaustedMinionIds(exhaustedState, action)
50 | ));
51 | default:
52 | return state;
53 | }
54 | }
55 |
56 | const getBoard = (state, props) => state[props].board.minionIds;
57 | const getMinions = state => state.entities.minions;
58 | export const boardSelector = createSelector([getBoard, getMinions], (minionIdsState, minions) => (
59 | minionIdsState.map(minionId => minions.get(minionId))
60 | ));
61 |
--------------------------------------------------------------------------------
/src/redux/modules/card.js:
--------------------------------------------------------------------------------
1 | import { Record as record } from 'immutable';
2 |
3 | /* eslint-disable import/prefer-default-export */
4 | // Reason: There might be more methods being exported here
5 | export const CardModel = record({
6 | id: null,
7 | name: '',
8 | mana: null,
9 | attack: null,
10 | defense: null,
11 | portrait: null,
12 | });
13 | /* eslint-enable */
14 |
--------------------------------------------------------------------------------
/src/redux/modules/character.js:
--------------------------------------------------------------------------------
1 | import { Record } from 'immutable';
2 | import { HIT_FACE } from './minion';
3 |
4 | const ADD_MAX_MANA = 'ADD_MANA';
5 | const ADD_SPENDABLE_MANA = 'ADD_SPENDABLE_MANA';
6 | const FILL_MAX_MANA = 'FILL_MAX_MANA';
7 | const SPEND_MANA = 'SPEND_MANA';
8 |
9 | export function addMaxMana({ target, fromServer, amount = 1 }) {
10 | return {
11 | target,
12 | amount,
13 | fromServer,
14 | type: ADD_MAX_MANA,
15 | };
16 | }
17 |
18 | export function addSpendableMana({ target, amount = 1 }) {
19 | return {
20 | target,
21 | amount,
22 | type: ADD_SPENDABLE_MANA,
23 | };
24 | }
25 |
26 | export function fillMaxMana({ target, fromServer }) {
27 | return {
28 | target,
29 | fromServer,
30 | type: FILL_MAX_MANA,
31 | };
32 | }
33 |
34 | export function addAndFillMana({ target, fromServer }) {
35 | return (dispatch) => {
36 | dispatch(addMaxMana({ target, fromServer }));
37 | dispatch(fillMaxMana({ target, fromServer }));
38 | };
39 | }
40 |
41 | export function spendMana({ target, amount }) {
42 | return {
43 | target,
44 | amount,
45 | type: SPEND_MANA,
46 | };
47 | }
48 |
49 | const manaReducerInitialState = new Record({
50 | max: 0,
51 | spendableMana: 0,
52 | });
53 |
54 | function manaReducer(state = manaReducerInitialState(), action) {
55 | switch (action.type) {
56 | case ADD_MAX_MANA:
57 | return state.update('max', max => max + action.amount);
58 | case ADD_SPENDABLE_MANA:
59 | return state.update('spendableMana', spendableMana => spendableMana + action.amount);
60 | case FILL_MAX_MANA:
61 | return state.set('spendableMana', state.get('max'));
62 | case SPEND_MANA:
63 | return state.update('spendableMana', spendableMana => spendableMana - action.amount);
64 | default:
65 | return state;
66 | }
67 | }
68 |
69 | const initialState = new Record({
70 | health: 30,
71 | mana: manaReducer(undefined, {}),
72 | });
73 |
74 | export default function characterReducer(state = initialState(), action) {
75 | switch (action.type) {
76 | case HIT_FACE:
77 | return state.update('health', health => health - action.damage);
78 | case ADD_MAX_MANA:
79 | case ADD_SPENDABLE_MANA:
80 | case FILL_MAX_MANA:
81 | case SPEND_MANA:
82 | return state.update('mana', mana => manaReducer(mana, action));
83 | default:
84 | return state;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/redux/modules/currentGame.js:
--------------------------------------------------------------------------------
1 | import { Record as record } from 'immutable';
2 | import { checkSuccessStatus, toJSON } from 'redux/utils/api';
3 |
4 | const NEW_GAME_REQUEST = 'NEW_GAME_REQUEST';
5 | const NEW_GAME_SUCCESS = 'NEW_GAME_SUCCESS';
6 | const NEW_GAME_FAILURE = 'NEW_GAME_FAILURE';
7 | const START_GAME = 'START_GAME';
8 | const UPDATE_HAS_OPPONENT = 'UPDATE_HAS_OPPONENT';
9 | const RESET_CURRENT_GAME = 'RESET_CURRENT_GAME';
10 |
11 | function newGameRequest() {
12 | return { type: NEW_GAME_REQUEST };
13 | }
14 |
15 | function newGameSuccess({ gameId }) {
16 | return {
17 | gameId,
18 | type: NEW_GAME_SUCCESS,
19 | };
20 | }
21 |
22 | function newGameFailure({ errors }) {
23 | return {
24 | errors,
25 | type: NEW_GAME_FAILURE,
26 | };
27 | }
28 |
29 | function shouldFetchNewGame(state, force) {
30 | const { currentGame } = state;
31 |
32 | if (force) return true;
33 | if (currentGame.get('gameId')) return false;
34 |
35 | return true;
36 | }
37 |
38 | export function fetchNewGame(force = false) {
39 | return (dispatch, getState) => {
40 | if (!shouldFetchNewGame(getState(), force)) return Promise.resolve();
41 |
42 | dispatch(newGameRequest());
43 |
44 | return fetch('http://localhost:3000/api/game/new', { method: 'post' })
45 | .then(checkSuccessStatus)
46 | .then(toJSON)
47 | .then((json) => {
48 | dispatch(newGameSuccess(json));
49 | return json.gameId;
50 | })
51 | .catch((errors) => { dispatch(newGameFailure({ errors })); });
52 | };
53 | }
54 |
55 | export function joinGame(gameId) {
56 | return newGameSuccess({ gameId });
57 | }
58 |
59 | export function startGame() {
60 | return { type: START_GAME };
61 | }
62 |
63 | export function updateHasOpponent(hasOpponent) {
64 | return {
65 | hasOpponent,
66 | type: UPDATE_HAS_OPPONENT,
67 | };
68 | }
69 |
70 | export function resetCurrentGame() {
71 | return {
72 | type: RESET_CURRENT_GAME,
73 | };
74 | }
75 |
76 | const initialState = record({
77 | loading: false,
78 | gameId: '',
79 | started: false,
80 | hasOpponent: false,
81 | errors: [],
82 | });
83 | export default function currentGameReducer(state = initialState(), action) {
84 | switch (action.type) {
85 | case NEW_GAME_REQUEST:
86 | return state.set('loading', true);
87 | case NEW_GAME_SUCCESS:
88 | return state.merge({
89 | loading: false,
90 | gameId: action.gameId,
91 | errors: [],
92 | });
93 | case NEW_GAME_FAILURE:
94 | return state.merge({
95 | loading: false,
96 | gameId: '',
97 | errors: action.errors,
98 | });
99 | case START_GAME:
100 | return state.set('started', true);
101 | case UPDATE_HAS_OPPONENT:
102 | return state.set('hasOpponent', action.hasOpponent);
103 | case RESET_CURRENT_GAME:
104 | return initialState();
105 | default:
106 | return state;
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/redux/modules/deck.js:
--------------------------------------------------------------------------------
1 | export const DRAW_CARD = 'DRAW_CARD';
2 |
3 | export function drawCard({ name, target, fromServer }) {
4 | return {
5 | name,
6 | target,
7 | fromServer,
8 | type: DRAW_CARD,
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/src/redux/modules/entities.js:
--------------------------------------------------------------------------------
1 | import { Map, Record } from 'immutable';
2 | import { PLAY_CARD } from './hand';
3 | import { HIT_MINION } from './minion';
4 |
5 | function minions(state = new Map(), action) {
6 | switch (action.type) {
7 | case PLAY_CARD:
8 | return state.set(action.card.id, action.card);
9 | case HIT_MINION:
10 | return state.update(action.minionId, minion => (
11 | minion.update('defense', defense => defense - action.damage)
12 | ));
13 | default:
14 | return state;
15 | }
16 | }
17 |
18 | const initialState = new Record({
19 | minions: minions(undefined, {}),
20 | });
21 | export default function entities(state = initialState(), action) {
22 | switch (action.type) {
23 | case PLAY_CARD:
24 | case HIT_MINION:
25 | return state.update('minions', minionState => minions(minionState, action));
26 | default:
27 | return state;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/redux/modules/friendInviteModal.js:
--------------------------------------------------------------------------------
1 | import { Record as record } from 'immutable';
2 |
3 | const TOGGLE_FRIEND_INVITE_MODAL = 'TOGGLE_FRIEND_INVITE_MODAL';
4 | const OPEN_FRIEND_INVITE_MODAL = 'OPEN_FRIEND_INVITE_MODAL';
5 | const CLOSE_FRIEND_INVITE_MODAL = 'CLOSE_FRIEND_INVITE_MODAL';
6 |
7 | export function toggleFriendInviteModal() {
8 | return { type: TOGGLE_FRIEND_INVITE_MODAL };
9 | }
10 |
11 | export function openFriendInviteModal() {
12 | return { type: OPEN_FRIEND_INVITE_MODAL };
13 | }
14 |
15 | export function closeFriendInviteModal() {
16 | return { type: CLOSE_FRIEND_INVITE_MODAL };
17 | }
18 |
19 | const initialState = record({
20 | isOpen: false,
21 | });
22 | export default function friendInviteModalReducer(state = initialState(), action) {
23 | switch (action.type) {
24 | case TOGGLE_FRIEND_INVITE_MODAL:
25 | return state.update('isOpen', isOpen => !isOpen);
26 | case OPEN_FRIEND_INVITE_MODAL:
27 | return state.set('isOpen', true);
28 | case CLOSE_FRIEND_INVITE_MODAL:
29 | return state.set('isOpen', false);
30 | default:
31 | return state;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/redux/modules/game.js:
--------------------------------------------------------------------------------
1 | import { drawCard } from './deck';
2 | import { addAndFillMana } from './character';
3 |
4 | export const NEW_GAME = 'NEW_GAME';
5 |
6 | function newGameAction({ yourName, opponentName, isPlayerStarting, fromServer }) {
7 | return {
8 | yourName,
9 | opponentName,
10 | isPlayerStarting,
11 | fromServer,
12 | type: NEW_GAME,
13 | };
14 | }
15 |
16 | export function newGame({ yourName, opponentName, isPlayerStarting, fromServer }) {
17 | return (dispatch) => {
18 | dispatch(newGameAction({ yourName, opponentName, isPlayerStarting, fromServer }));
19 | dispatch(addAndFillMana({ fromServer, target: isPlayerStarting ? 'PLAYER' : 'OPPONENT' }));
20 | dispatch(drawCard({
21 | fromServer,
22 | name: 'The Coin',
23 | target: isPlayerStarting ? 'OPPONENT' : 'PLAYER',
24 | }));
25 | };
26 | }
27 |
--------------------------------------------------------------------------------
/src/redux/modules/hand.js:
--------------------------------------------------------------------------------
1 | import createReducer from 'redux/utils/createReducer';
2 | import newCardByName, { newRandomCard } from 'redux/utils/cards';
3 | import { List } from 'immutable';
4 | import { DRAW_CARD } from './deck';
5 | import { spendMana } from './character';
6 |
7 | const MAX_CARDS = 10;
8 | export const PLAY_CARD = 'PLAY_CARD';
9 | export const PLAY_CARD_WITH_COST = 'PLAY_CARD_WITH_COST';
10 |
11 | const initialState = new List([
12 | newCardByName('Anima Golem'),
13 | newCardByName('Abusive Sergeant'),
14 | newCardByName('Acolyte of Pain'),
15 | newCardByName('Azure Drake'),
16 | ]);
17 |
18 | export function playCard({ target, card, handIndex, boardIndex, source }) {
19 | return {
20 | target,
21 | card,
22 | handIndex,
23 | boardIndex,
24 | source,
25 | type: PLAY_CARD,
26 | };
27 | }
28 |
29 | export function playCardWithCost({ target, card, handIndex, boardIndex, source }) {
30 | return (dispatch, getState) => {
31 | const targetPlayer = target === 'PLAYER' ? 'player' : 'opponent';
32 | const { mana } = getState()[targetPlayer].character;
33 |
34 | if (mana.spendableMana < card.mana) return;
35 | dispatch(playCard({ target, card, handIndex, boardIndex, source }));
36 | dispatch(spendMana({ target, amount: card.mana }));
37 | };
38 | }
39 |
40 | function drawCardHandler(state, action) {
41 | if (state.size + 1 > MAX_CARDS) return state;
42 | if (action.name) {
43 | return state.push(newCardByName(action.name));
44 | }
45 | return state.push(newRandomCard());
46 | }
47 |
48 | function playCardHandler(state, action) {
49 | return state.delete(action.handIndex);
50 | }
51 |
52 | const handlers = {
53 | [DRAW_CARD]: drawCardHandler,
54 | [PLAY_CARD]: playCardHandler,
55 | };
56 |
57 | export default createReducer(initialState, handlers);
58 |
--------------------------------------------------------------------------------
/src/redux/modules/lobby.js:
--------------------------------------------------------------------------------
1 | import friendInviteModal from './friendInviteModal';
2 |
3 | const initialState = {
4 | friendInviteModal: friendInviteModal(undefined, {}),
5 | };
6 | export default function lobbyReducer(state = initialState, action) {
7 | return {
8 | friendInviteModal: friendInviteModal(state.friendInviteModal, action),
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/src/redux/modules/minion.js:
--------------------------------------------------------------------------------
1 | export const EXHAUST_MINION = 'EXHAUST_MINION';
2 | export const ATTACK_MINION = 'ATTACK_MINION';
3 | export const HIT_MINION = 'HIT_MINION';
4 | export const HIT_FACE = 'HIT_FACE';
5 | export const KILL_MINION = 'KILL_MINION';
6 |
7 | function exhaustMinion({ source, minionId }) {
8 | return {
9 | source,
10 | minionId,
11 | type: EXHAUST_MINION,
12 | };
13 | }
14 |
15 | export function hitMinion({ minionId, damage }) {
16 | return {
17 | minionId,
18 | damage,
19 | type: HIT_MINION,
20 | };
21 | }
22 |
23 | export function killMinion({ target, minionId }) {
24 | return {
25 | target,
26 | minionId,
27 | type: KILL_MINION,
28 | };
29 | }
30 |
31 | export function attackMinion({ target, targetMinion, source, sourceMinion }) {
32 | return (dispatch) => {
33 | dispatch({ target, targetMinion, source, sourceMinion, type: ATTACK_MINION });
34 | dispatch(exhaustMinion({ source, minionId: sourceMinion.id }));
35 | dispatch(hitMinion({ minionId: targetMinion.id, damage: sourceMinion.attack }));
36 | dispatch(hitMinion({ minionId: sourceMinion.id, damage: targetMinion.attack }));
37 |
38 | if (targetMinion.defense - sourceMinion.attack <= 0) {
39 | dispatch(killMinion({ target, minionId: targetMinion.id }));
40 | }
41 |
42 | if (sourceMinion.defense - targetMinion.attack <= 0) {
43 | dispatch(killMinion({ target: source, minionId: sourceMinion.id }));
44 | }
45 | };
46 | }
47 |
48 | export function hitFace({ source, sourceMinionId, target, damage }) {
49 | return (dispatch) => {
50 | dispatch(exhaustMinion({ source, minionId: sourceMinionId }));
51 | dispatch({ target, damage, type: HIT_FACE });
52 | };
53 | }
54 |
--------------------------------------------------------------------------------
/src/redux/modules/name.js:
--------------------------------------------------------------------------------
1 | const CHANGE_NAME = 'CHANGE_NAME';
2 |
3 | export function setName({ name, target }) {
4 | return {
5 | name,
6 | target,
7 | type: CHANGE_NAME,
8 | };
9 | }
10 |
11 | export function setPlayerName(name) {
12 | return setName({ name, target: 'PLAYER' });
13 | }
14 |
15 | export function setOpponentName(name) {
16 | return setName({ name, target: 'OPPONENT' });
17 | }
18 |
19 | export default function nameReducer(state = '', action) {
20 | if (action.type === CHANGE_NAME) return action.name;
21 |
22 | return state;
23 | }
24 |
--------------------------------------------------------------------------------
/src/redux/modules/opponent.js:
--------------------------------------------------------------------------------
1 | import nameReducer from './name';
2 | import readyReducer from './ready';
3 | import characterReducer from './character';
4 | import boardReducer from './board';
5 |
6 | function handCountReducer(state = 2) {
7 | return state;
8 | }
9 |
10 | function deckCountReducer(state = 30) {
11 | return state;
12 | }
13 |
14 | export default function opponentReducer(state = {}, action) {
15 | if (action.target && action.target !== 'OPPONENT') return state;
16 |
17 | return {
18 | name: nameReducer(state.name, action),
19 | ready: readyReducer(state.ready, action),
20 | character: characterReducer(state.character, action),
21 | handCount: handCountReducer(state.handCount, action),
22 | deckCount: deckCountReducer(state.deckCount, action),
23 | board: boardReducer(state.board, action),
24 | };
25 | }
26 |
--------------------------------------------------------------------------------
/src/redux/modules/player.js:
--------------------------------------------------------------------------------
1 | import name from './name';
2 | import ready from './ready';
3 | import character from './character';
4 | import hand from './hand';
5 | import board from './board';
6 |
7 | export default function playerReducer(state = {}, action) {
8 | if (action.target && action.target !== 'PLAYER') return state;
9 |
10 | return {
11 | name: name(state.name, action),
12 | ready: ready(state.ready, action),
13 | character: character(state.character, action),
14 | hand: hand(state.hand, action),
15 | board: board(state.board, action),
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/redux/modules/ready.js:
--------------------------------------------------------------------------------
1 | const SET_PLAYER_READY = 'SET_PLAYER_READY';
2 | const TOGGLE_PLAYER_READY = 'TOGGLE_PLAYER_READY';
3 |
4 | export function setReady({ readyState, target }) {
5 | return {
6 | readyState,
7 | target,
8 | source: target,
9 | type: SET_PLAYER_READY,
10 | };
11 | }
12 |
13 | export function toggleReady({ target }) {
14 | return {
15 | target,
16 | source: target,
17 | type: TOGGLE_PLAYER_READY,
18 | };
19 | }
20 |
21 | export function resetReady({ target }) {
22 | return setReady({ target, to: false });
23 | }
24 |
25 | export default function ready(state = false, action) {
26 | switch (action.type) {
27 | case SET_PLAYER_READY:
28 | return action.to;
29 | case TOGGLE_PLAYER_READY:
30 | return !state;
31 | default:
32 | return state;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/redux/modules/rootReducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import entities from './entities';
3 | import yourTurn from './yourTurn';
4 | import player from './player';
5 | import opponent from './opponent';
6 | import currentGame from './currentGame';
7 | import lobby from './lobby';
8 |
9 | export default combineReducers({
10 | yourTurn,
11 | currentGame,
12 | lobby,
13 | entities,
14 | player,
15 | opponent,
16 | });
17 |
--------------------------------------------------------------------------------
/src/redux/modules/yourTurn.js:
--------------------------------------------------------------------------------
1 | import { drawCard } from './deck';
2 | import { NEW_GAME } from './game';
3 | import { addAndFillMana } from './character';
4 |
5 | export const END_TURN = 'END_TURN';
6 |
7 | export function endTurn() {
8 | return (dispatch, getState) => {
9 | const { yourTurn: currentYourTurn } = getState();
10 | const target = currentYourTurn ? 'OPPONENT' : 'PLAYER';
11 |
12 | dispatch(addAndFillMana({ target }));
13 | dispatch(drawCard({ target }));
14 | dispatch({ type: END_TURN });
15 | };
16 | }
17 |
18 | export default function yourTurn(state = true, action) {
19 | switch (action.type) {
20 | case NEW_GAME:
21 | return action.isPlayerStarting;
22 | case END_TURN:
23 | return !state;
24 | default:
25 | return state;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/redux/utils/api.js:
--------------------------------------------------------------------------------
1 | export function checkSuccessStatus(response) {
2 | if (response.status !== 200) {
3 | return Promise.reject(response);
4 | }
5 | return Promise.resolve(response);
6 | }
7 |
8 | export function toJSON(response) {
9 | return response.json();
10 | }
11 |
--------------------------------------------------------------------------------
/src/redux/utils/cards.js:
--------------------------------------------------------------------------------
1 | import uuid from 'uuid';
2 | import { CardModel } from 'redux/modules/card';
3 |
4 | const cards = [
5 | {
6 | name: 'Anima Golem',
7 | mana: 6,
8 | attack: 9,
9 | defense: 9,
10 | portrait: 'http://hydra-media.cursecdn.com/hearthstone.gamepedia.com/thumb/5/54/Anima_Golem_full.jpg/459px-Anima_Golem_full.jpg?version=1624263f4c524b56f97dd80fdd3d9bf2',
11 | }, {
12 | name: 'Abusive Sergeant',
13 | mana: 1,
14 | attack: 2,
15 | defense: 1,
16 | portrait: 'http://hydra-media.cursecdn.com/hearthstone.gamepedia.com/thumb/1/18/Abusive_Sergeant_full.jpg/587px-Abusive_Sergeant_full.jpg?version=80b05953c8897d68d12706490c8ab68d',
17 | }, {
18 | name: 'Acolyte of Pain',
19 | mana: 3,
20 | attack: 1,
21 | defense: 3,
22 | portrait: 'http://hydra-media.cursecdn.com/hearthstone.gamepedia.com/thumb/e/e0/Acolyte_of_Pain_full.jpg/350px-Acolyte_of_Pain_full.jpg?version=ea71ea7aef056d51f01800584e74c1bb',
23 | }, {
24 | name: 'Azure Drake',
25 | mana: 5,
26 | attack: 4,
27 | defense: 4,
28 | portrait: 'http://hydra-media.cursecdn.com/hearthstone.gamepedia.com/thumb/6/64/Azure_Drake_full.jpg/782px-Azure_Drake_full.jpg?version=6104b0c3caf640057fb10ca860778635',
29 | }, {
30 | name: 'Rare Parrot',
31 | mana: 10,
32 | attack: 0,
33 | defense: 10,
34 | portrait: 'http://i.imgur.com/PYe4A3T.gif',
35 | }, {
36 | name: 'James Kappa',
37 | mana: 3,
38 | attack: 16,
39 | defense: 5,
40 | portrait: 'http://media.steampowered.com/steamcommunity/public/images/avatars/0c/0c32e686f6c202e65de64bacb32eaea0c6b517f0_full.jpg',
41 | },
42 | ];
43 |
44 | const cardsByName = {};
45 | for (const card of cards) {
46 | cardsByName[card.name.toLowerCase()] = card;
47 | }
48 | cardsByName['the coin'] = {
49 | name: 'The Coin',
50 | mana: 0,
51 | attack: 0,
52 | defense: 0,
53 | portrait: 'https://hydra-media.cursecdn.com/hearthstone.gamepedia.com/a/a9/The_Coin_full.jpg',
54 | };
55 |
56 | export default function newCardByName(name = '') {
57 | const card = cardsByName[name.toLowerCase()];
58 | if (!card) throw new Error('There is no card with that name');
59 | return new CardModel(Object.assign({}, card, { id: uuid.v4() }));
60 | }
61 |
62 | export function newRandomCard() {
63 | const randomCard = cards[Math.floor(Math.random() * cards.length)];
64 | return newCardByName(randomCard.name);
65 | }
66 |
--------------------------------------------------------------------------------
/src/redux/utils/createReducer.js:
--------------------------------------------------------------------------------
1 | export default function createReducer(initialState, actionHandlers) {
2 | return (state = initialState, action) => {
3 | const handler = actionHandlers[action.type];
4 | if (handler) {
5 | return handler(state, action);
6 | }
7 | return state;
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/src/redux/utils/dispatchNewGameAction.js:
--------------------------------------------------------------------------------
1 | import { newGame } from 'redux/modules/game';
2 |
3 | export default function dispatchNewGameAction(store, socket) {
4 | socket.on('newGame', (payload) => {
5 | const { opponentName, isStarting } = payload;
6 | const { player } = store.getState();
7 | const action = newGame({
8 | opponentName,
9 | yourName: player.name,
10 | isPlayerStarting: isStarting,
11 | fromServer: true,
12 | });
13 |
14 | store.dispatch(action);
15 | });
16 | }
17 |
--------------------------------------------------------------------------------
/src/redux/utils/dispatchServerActions.js:
--------------------------------------------------------------------------------
1 | import { CardModel } from 'redux/modules/card';
2 |
3 | export default function dispatchServerActions(store, socket) {
4 | socket.on('action', (payload) => {
5 | console.log('action came in through socket!!!', payload);
6 | const { action } = payload;
7 |
8 | if (action.card) {
9 | action.card = new CardModel(action.card);
10 | }
11 |
12 | store.dispatch({ fromServer: true, ...action });
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/src/redux/utils/getInitialState.js:
--------------------------------------------------------------------------------
1 | export default function getInitialState() {
2 | return {
3 | player: {
4 | name: localStorage.getItem('reduxPlayerName') || '',
5 | },
6 | };
7 | }
8 |
--------------------------------------------------------------------------------
/src/redux/utils/index.js:
--------------------------------------------------------------------------------
1 | export dispatchNewGameAction from './dispatchNewGameAction';
2 | export dispatchServerActions from './dispatchServerActions';
3 | export getInitialState from './getInitialState';
4 |
--------------------------------------------------------------------------------
/src/styles/app.scss:
--------------------------------------------------------------------------------
1 | @charset 'utf-8';
2 |
3 | @import 'vendor/reset';
4 | @import 'base/base';
5 | @import 'base/forms';
6 |
--------------------------------------------------------------------------------
/src/styles/base/base.scss:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | html, body, #app {
6 | width: 100%;
7 | height: 100%;
8 | margin: 0;
9 | padding: 0;
10 | font-family: 'Helvetica', sans-serif;
11 | }
12 |
13 | h1, h2, h3, h4, h5, h6 {
14 | margin: 0;
15 | padding: 0;
16 | }
17 |
--------------------------------------------------------------------------------
/src/styles/base/forms.scss:
--------------------------------------------------------------------------------
1 | input,
2 | .input {
3 | border: 0;
4 | }
5 |
6 | .input--group,
7 | .input--base {
8 | background: #ffffff;
9 | border: 1px solid #ccc;
10 | border-radius: 4px;
11 | padding: 10px;
12 | }
13 |
14 | .input--base,
15 | .input--group__input {
16 | font-size: 14px;
17 | }
18 |
19 | .input--group {
20 | display: flex;
21 | }
22 |
23 | .input--full {
24 | width: 100%;
25 | }
26 |
--------------------------------------------------------------------------------
/src/styles/vendor/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html, body, div, span, applet, object, iframe,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | a, abbr, acronym, address, big, cite, code,
9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed,
16 | figure, figcaption, footer, header, hgroup,
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video {
19 | margin: 0;
20 | padding: 0;
21 | border: 0;
22 | font-size: 100%;
23 | font: inherit;
24 | vertical-align: baseline;
25 | }
26 | /* HTML5 display-role reset for older browsers */
27 | article, aside, details, figcaption, figure,
28 | footer, header, hgroup, menu, nav, section {
29 | display: block;
30 | }
31 | body {
32 | line-height: 1;
33 | }
34 | ol, ul {
35 | list-style: none;
36 | }
37 | blockquote, q {
38 | quotes: none;
39 | }
40 | blockquote:before, blockquote:after,
41 | q:before, q:after {
42 | content: '';
43 | content: none;
44 | }
45 | table {
46 | border-collapse: collapse;
47 | border-spacing: 0;
48 | }
49 |
--------------------------------------------------------------------------------
/src/views/GameLobbyScreen/GameLobbyScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { bindActionCreators, compose } from 'redux';
3 | import { connect } from 'react-redux';
4 | import { withRouter } from 'react-router';
5 |
6 | import { withSocket } from 'hoc';
7 | import { GameLobby } from 'components';
8 | import { setReady, toggleReady } from 'redux/modules/ready';
9 | import { startGame, updateHasOpponent, resetCurrentGame } from 'redux/modules/currentGame';
10 | import { setOpponentName } from 'redux/modules/name';
11 | import { openFriendInviteModal, closeFriendInviteModal } from 'redux/modules/friendInviteModal';
12 |
13 | export class GameLobbyScreen extends Component {
14 | static propTypes = {
15 | player: PropTypes.shape({
16 | name: PropTypes.string.isRequired,
17 | ready: PropTypes.bool.isRequired,
18 | }).isRequired,
19 | opponent: PropTypes.shape({
20 | name: PropTypes.string.isRequired,
21 | ready: PropTypes.bool.isRequired,
22 | }).isRequired,
23 | gameId: PropTypes.string.isRequired,
24 | hasOpponent: PropTypes.bool.isRequired,
25 | started: PropTypes.bool.isRequired,
26 | friendInviteModal: PropTypes.shape({
27 | isOpen: PropTypes.bool.isRequired,
28 | }).isRequired,
29 | actions: PropTypes.shape({
30 | setReady: PropTypes.func.isRequired,
31 | toggleReady: PropTypes.func.isRequired,
32 | startGame: PropTypes.func.isRequired,
33 | updateHasOpponent: PropTypes.func.isRequired,
34 | setOpponentName: PropTypes.func.isRequired,
35 | resetCurrentGame: PropTypes.func.isRequired,
36 | }),
37 | playerCardActions: PropTypes.shape({
38 | openFriendInviteModal: PropTypes.func.isRequired,
39 | closeFriendInviteModal: PropTypes.func.isRequired,
40 | }),
41 | router: PropTypes.shape({
42 | push: PropTypes.func.isRequired,
43 | createHref: PropTypes.func.isRequired,
44 | }).isRequired,
45 | socket: PropTypes.shape({
46 | id: PropTypes.string.isRequired,
47 | emit: PropTypes.func.isRequired,
48 | on: PropTypes.func.isRequired,
49 | }).isRequired,
50 | }
51 |
52 | state = {
53 | canCountdown: true,
54 | }
55 |
56 | componentDidMount = () => {
57 | this.joinGame(this.props);
58 | this.notifyOnPlayerJoined(this.props);
59 | }
60 |
61 | componentWillReceiveProps = (nextProps) => {
62 | if (nextProps.gameId !== this.props.gameId) {
63 | this.joinGame(nextProps);
64 | }
65 |
66 | if (nextProps.player.ready && nextProps.opponent.ready) {
67 | this.startCountdown();
68 | } else {
69 | this.stopCountdown();
70 | }
71 | }
72 |
73 | componentWillUnmount = () => {
74 | if (!this.props.started) {
75 | this.leaveGame(this.props);
76 | }
77 |
78 | this.removeOnPlayerJoinedListener(this.props);
79 | }
80 |
81 | startCountdown = () => this.setState(() => ({ canCountdown: true }));
82 | stopCountdown = () => this.setState(() => ({ canCountdown: false }));
83 |
84 | handleCountdownFinish = () => {
85 | this.startGame(this.props);
86 | this.goInGame(this.props);
87 | }
88 |
89 | notifyOnPlayerJoined = (props) => {
90 | props.socket.on('playerJoined', ({ socketId, name, playerCount }) => {
91 | if (this.props.socket.id !== socketId) {
92 | props.actions.setOpponentName(name);
93 | }
94 |
95 | if (playerCount === 2) {
96 | props.actions.updateHasOpponent(true);
97 | }
98 | });
99 | }
100 |
101 | removeOnPlayerJoinedListener = (props) => {
102 | props.socket.removeAllListeners('playerJoined');
103 | }
104 |
105 | emitReadyChange = (props, readyState) => {
106 | const { socket, gameId } = props;
107 |
108 | socket.emit('readyChange', { gameId, readyState });
109 | }
110 |
111 | startGame = (props) => {
112 | const { socket, gameId, actions } = props;
113 |
114 | socket.emit('gameStart', { gameId });
115 | actions.startGame();
116 | }
117 |
118 | joinGame = (props) => {
119 | const { socket, gameId, player: { name } } = props;
120 |
121 | socket.emit('gameJoin', { gameId, name });
122 | }
123 |
124 | leaveGame = (props) => {
125 | const { socket, gameId, actions } = props;
126 |
127 | socket.emit('gameLeave', { gameId });
128 | actions.resetCurrentGame();
129 | }
130 |
131 | goInGame = (props) => {
132 | const { router, gameId } = props;
133 |
134 | router.push(`/game/${gameId}`);
135 | }
136 |
137 | toggleReadyForPlayer = () => {
138 | this.props.actions.toggleReady({ target: 'PLAYER' });
139 | this.emitReadyChange(this.props, !this.props.player.ready);
140 | }
141 |
142 | render() {
143 | const { canCountdown } = this.state;
144 | const { gameId } = this.props;
145 | const { createHref } = this.props.router;
146 | const { protocol, host } = window.location;
147 | const countdown = { canCountdown, onFinish: this.handleCountdownFinish };
148 | const inviteLink = `${protocol}//${host}/${createHref(`/game/${gameId}/join`)}`;
149 |
150 | return (
151 |
157 | );
158 | }
159 | }
160 |
161 | const mapStateToProps = ({ player, opponent, currentGame, lobby }) => {
162 | const { gameId, started, hasOpponent } = currentGame;
163 | const { friendInviteModal } = lobby;
164 |
165 | return {
166 | player,
167 | opponent,
168 | gameId,
169 | started,
170 | hasOpponent,
171 | friendInviteModal,
172 | };
173 | };
174 | const mapDispatchToProps = dispatch => ({
175 | actions: bindActionCreators({
176 | setReady,
177 | toggleReady,
178 | startGame,
179 | updateHasOpponent,
180 | setOpponentName,
181 | resetCurrentGame,
182 | }, dispatch),
183 | playerCardActions: bindActionCreators({
184 | openFriendInviteModal,
185 | closeFriendInviteModal,
186 | }, dispatch),
187 | });
188 |
189 | export default compose(
190 | withSocket,
191 | withRouter,
192 | connect(mapStateToProps, mapDispatchToProps)
193 | )(GameLobbyScreen);
194 |
--------------------------------------------------------------------------------
/src/views/GameNewScreen/GameNewScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { withRouter } from 'react-router';
3 | import { compose, bindActionCreators } from 'redux';
4 | import { connect } from 'react-redux';
5 |
6 | import {
7 | updateHasOpponent,
8 | joinGame,
9 | fetchNewGame,
10 | } from 'redux/modules/currentGame';
11 |
12 | const imageUrl = 'http://chimplyimage.appspot.com/images/samples/classic-spinner/animatedCircle.gif';
13 |
14 | function emitGameJoin(socket, gameId) {
15 | socket.emit('gameJoin', { gameId });
16 | }
17 |
18 | export class GameNewScreen extends Component {
19 | static propTypes = {
20 | router: PropTypes.shape({
21 | createHref: PropTypes.func.isRequired,
22 | push: PropTypes.func.isRequired,
23 | }).isRequired,
24 | joinGame: PropTypes.func.isRequired,
25 | fetchNewGame: PropTypes.func.isRequired,
26 | updateHasOpponent: PropTypes.func.isRequired,
27 | currentGame: PropTypes.shape({
28 | loading: PropTypes.bool.isRequired,
29 | gameId: PropTypes.string.isRequired,
30 | hasOpponent: PropTypes.bool.isRequired,
31 | }).isRequired,
32 | socket: PropTypes.shape({
33 | emit: PropTypes.func.isRequired,
34 | on: PropTypes.func.isRequired,
35 | }).isRequired,
36 | }
37 |
38 | constructor(props) {
39 | super(props);
40 | this.addGameJoinedEventHandler = this.addGameJoinedEventHandler.bind(this);
41 | this.fetchNewGame = this.fetchNewGame.bind(this);
42 | this.joinNewGame = this.joinNewGame.bind(this);
43 | this.goInGame = this.goInGame.bind(this);
44 | this.openAlert = this.openAlert.bind(this);
45 | }
46 |
47 | componentDidMount() {
48 | const { socket } = this.props;
49 |
50 | this.props.fetchNewGame(true).then((gameId) => {
51 | emitGameJoin(socket, gameId);
52 | });
53 | }
54 |
55 | componentWillReceiveProps(nextProps) {
56 | const { gameId } = this.props.currentGame;
57 |
58 | if (gameId !== nextProps.currentGame.gameId) {
59 | this.addGameJoinedEventHandler(nextProps.currentGame.gameId);
60 | }
61 | }
62 |
63 | addGameJoinedEventHandler(gameId) {
64 | const { socket } = this.props;
65 |
66 | socket.on('playerJoined', ({ playerCount }) => {
67 | if (playerCount === 2) {
68 | this.props.updateHasOpponent(true);
69 | this.goInGame(gameId);
70 | }
71 | });
72 | }
73 |
74 | fetchNewGame() {
75 | this.props.fetchNewGame(true);
76 | }
77 |
78 | joinNewGame(gameId) {
79 | const { socket } = this.props;
80 |
81 | this.props.joinGame(gameId);
82 | emitGameJoin(socket, gameId);
83 | }
84 |
85 | goInGame(gameId) {
86 | const { router } = this.props;
87 |
88 | router.push(`/game/${gameId}`);
89 | }
90 |
91 | openAlert() {
92 | const gameId = prompt('What game do you want to join?');
93 |
94 | if (gameId) {
95 | this.joinNewGame(gameId);
96 | }
97 | }
98 |
99 | render() {
100 | const { createHref } = this.props.router;
101 | const { loading, gameId, hasOpponent } = this.props.currentGame;
102 | const { protocol, host } = window.location;
103 |
104 | return (
105 |
106 |
Loading: { loading ?
: 'no' }
107 |
found opponent? { hasOpponent ? 'yes' : 'no' }
108 | { gameId ? (
109 |
110 |
Hey dude, send this link to your friend 😁
111 |
112 | { loading ?
113 | 'regenerating...' :
114 | `${protocol}//${host}/${createHref(`/game/${gameId}/lobby`)}`
115 | }
116 |
117 |
118 |
119 |
120 | ) : null }
121 |
122 | );
123 | }
124 | }
125 |
126 | function mapStateToProps({ currentGame }) {
127 | return { currentGame };
128 | }
129 |
130 | function mapDispatchToProps(dispatch) {
131 | return {
132 | joinGame: bindActionCreators(joinGame, dispatch),
133 | fetchNewGame: bindActionCreators(fetchNewGame, dispatch),
134 | updateHasOpponent: bindActionCreators(updateHasOpponent, dispatch),
135 | };
136 | }
137 |
138 | export default compose(
139 | connect(mapStateToProps, mapDispatchToProps),
140 | withRouter,
141 | )(GameNewScreen);
142 |
--------------------------------------------------------------------------------
/src/views/GameScreen/GameScreen.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable class-methods-use-this */
2 | import React, { Component, PropTypes } from 'react';
3 | import { compose } from 'redux';
4 | import { withRouter } from 'react-router';
5 | import { connect } from 'react-redux';
6 | import { Board } from 'containers';
7 |
8 | export class GameScreen extends Component {
9 | static propTypes = {
10 | router: PropTypes.shape({
11 | push: PropTypes.func.isRequired,
12 | }).isRequired,
13 | currentGame: PropTypes.shape({
14 | loading: PropTypes.bool.isRequired,
15 | gameId: PropTypes.string.isRequired,
16 | hasOpponent: PropTypes.bool.isRequired,
17 | }).isRequired,
18 | }
19 |
20 | render() {
21 | return ;
22 | }
23 | }
24 |
25 | function mapStateToProps({ currentGame }) {
26 | return { currentGame };
27 | }
28 |
29 | export default compose(
30 | connect(mapStateToProps),
31 | withRouter,
32 | )(GameScreen);
33 |
--------------------------------------------------------------------------------
/src/views/StartScreen/StartScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { bindActionCreators, compose } from 'redux';
3 | import { connect } from 'react-redux';
4 | import { withRouter, Link } from 'react-router';
5 |
6 | import { NewPlayerForm } from 'containers';
7 | import { setPlayerName } from 'redux/modules/name';
8 |
9 | export class StartScreen extends Component {
10 | static propTypes = {
11 | router: PropTypes.shape({
12 | replace: PropTypes.func.isRequired,
13 | }).isRequired,
14 | location: PropTypes.shape({
15 | query: PropTypes.shape({
16 | ref: PropTypes.string,
17 | }).isRequired,
18 | }).isRequired,
19 | playerName: PropTypes.string.isRequired,
20 | setPlayerName: PropTypes.func.isRequired,
21 | }
22 |
23 | componentDidMount = () => {
24 | this.goToRef(this.props);
25 | }
26 |
27 | componentWillReceiveProps = (nextProps) => {
28 | this.goToRef(nextProps);
29 | }
30 |
31 | goToRef = (props) => {
32 | const { router, location, playerName } = props;
33 | const { ref } = location.query;
34 |
35 | if (playerName && ref) {
36 | router.replace(ref);
37 | }
38 | }
39 |
40 | render() {
41 | const { playerName } = this.props;
42 |
43 | return (
44 |
45 |
Welcome to HearthStone{ playerName && `, ${playerName}` }
46 | { playerName ? (
47 |
48 |
49 |
50 |
51 | ) : (
52 |
53 | ) }
54 |
55 | );
56 | }
57 | }
58 |
59 | const mapStateToProps = state => ({ playerName: state.player.name });
60 | const mapDispatchToProps = dispatch => (bindActionCreators({ setPlayerName }, dispatch));
61 |
62 | export default compose(
63 | withRouter,
64 | connect(mapStateToProps, mapDispatchToProps)
65 | )(StartScreen);
66 |
67 |
--------------------------------------------------------------------------------
/src/views/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-named-as-default */
2 | export GameLobbyScreen from './GameLobbyScreen/GameLobbyScreen';
3 | export GameNewScreen from './GameNewScreen/GameNewScreen';
4 | export GameScreen from './GameScreen/GameScreen';
5 | export StartScreen from './StartScreen/StartScreen';
6 |
--------------------------------------------------------------------------------
/webpack/handlers/index.js:
--------------------------------------------------------------------------------
1 | exports.onActionHandler = require('./onAction');
2 | exports.onGameJoinHandler = require('./onGameJoin');
3 | exports.onGameLeaveHandler = require('./onGameLeave');
4 | exports.onGameStartHandler = require('./onGameStart');
5 |
--------------------------------------------------------------------------------
/webpack/handlers/onAction.js:
--------------------------------------------------------------------------------
1 | const {
2 | colors,
3 | createLogger,
4 | clientsForRoom,
5 | isClientInRoom,
6 | } = require('./../utils');
7 |
8 | const logAction = createLogger('ACTION', colors.onAction);
9 |
10 | function onAction({ gameId, opponentHandCount, action }) {
11 | logAction('current clients:', clientsForRoom(this.io, gameId));
12 | logAction('Current action from:',
13 | `Player${clientsForRoom(this.io, gameId).indexOf(this.socket.id) + 1}`);
14 |
15 | // Validate if player is actually part of this game.
16 | if (!isClientInRoom(this.io, gameId, this.socket.id)) return;
17 |
18 | let newAction = Object.assign({}, action, { opponentHandCount });
19 | if (action.target || action.source) {
20 | newAction = Object.assign({}, newAction, {
21 | source: action.source === 'PLAYER' ? 'OPPONENT' : 'PLAYER',
22 | target: action.target === 'PLAYER' ? 'OPPONENT' : 'PLAYER',
23 | });
24 | }
25 |
26 | logAction('Broadcasting an action to:', gameId);
27 | logAction('Action being sent is:', newAction);
28 | this.socket.broadcast.to(gameId).emit('action', { action: newAction });
29 | }
30 |
31 | module.exports = onAction;
32 |
--------------------------------------------------------------------------------
/webpack/handlers/onGameJoin.js:
--------------------------------------------------------------------------------
1 | const {
2 | colors,
3 | createLogger,
4 | lengthOfRoom,
5 | clientsForRoom,
6 | } = require('./../utils');
7 |
8 | const logGameJoin = createLogger('GAMEJOIN', colors.onGameJoin);
9 |
10 | function onGameJoin({ gameId, name }) {
11 | const getPlayerCount = () => lengthOfRoom(this.io, gameId);
12 |
13 | if (getPlayerCount() === 2) return;
14 |
15 | this.socket.username = name;
16 | this.socket.join(gameId);
17 | this.io.to(gameId).emit('playerJoined', {
18 | gameId,
19 | name,
20 | socketId: this.socket.id,
21 | playerCount: getPlayerCount(),
22 | });
23 |
24 | if (getPlayerCount() === 2) {
25 | const enemySocketId = clientsForRoom(this.io, gameId).find(id => id !== this.socket.id);
26 | const enemyPlayerName = this.io.sockets.connected[enemySocketId].username;
27 |
28 | this.io.to(this.socket.id).emit('playerJoined', {
29 | gameId,
30 | name: enemyPlayerName,
31 | socketId: enemySocketId,
32 | playerCount: getPlayerCount(),
33 | });
34 | }
35 |
36 | logGameJoin('Current players:', clientsForRoom(this.io, gameId));
37 | }
38 |
39 | module.exports = onGameJoin;
40 |
--------------------------------------------------------------------------------
/webpack/handlers/onGameLeave.js:
--------------------------------------------------------------------------------
1 | const {
2 | colors,
3 | createLogger,
4 | } = require('./../utils');
5 |
6 | const log = createLogger('GAMELEAVE', colors.onGameLeave);
7 |
8 | // TODO: implment in client and notify other players
9 | function onGameLeave({ gameId }) {
10 | log(`${this.socket.username} has left the game: ${gameId}`);
11 | this.socket.leave(gameId);
12 | }
13 |
14 | module.exports = onGameLeave;
15 |
--------------------------------------------------------------------------------
/webpack/handlers/onGameStart.js:
--------------------------------------------------------------------------------
1 | const {
2 | colors,
3 | createLogger,
4 | lengthOfRoom,
5 | clientsForRoom,
6 | newGame,
7 | } = require('./../utils');
8 |
9 | const log = createLogger('GAMESTART', colors.onGameStart);
10 |
11 | function onGameStart({ gameId }) {
12 | const getPlayerCount = () => lengthOfRoom(this.io, gameId);
13 |
14 | log('LETS START THE GAME', gameId);
15 | log('player count:', getPlayerCount());
16 |
17 | if (getPlayerCount() === 2) {
18 | log('[START] Time to start the game', gameId);
19 | const playerOneStarts = Math.random() >= 0.5;
20 | const [playerOne, playerTwo] = clientsForRoom(this.io, gameId);
21 |
22 | // If player one did not fire off this event, don't do anything,
23 | // because player one already started.
24 | if (this.socket.id !== playerOne) return;
25 |
26 | const playerOneNewGame = newGame(gameId, playerOneStarts);
27 | this.io.to(playerOne).emit('newGame', playerOneNewGame);
28 | log('[ACTION] Sent action to playerOne', playerOneNewGame);
29 |
30 | const playerTwoNewGame = newGame(gameId, !playerOneStarts);
31 | this.io.to(playerTwo).emit('newGame', playerTwoNewGame);
32 | log('[ACTION] Sent action to playerTwo', playerTwoNewGame);
33 | }
34 | }
35 |
36 | module.exports = onGameStart;
37 |
--------------------------------------------------------------------------------
/webpack/routes/api/game.js:
--------------------------------------------------------------------------------
1 | const uuid = require('uuid');
2 |
3 | exports.generateNewGameId = function generateNewGameId(req, res) {
4 | setTimeout(() => {
5 | res.json({
6 | gameId: uuid.v4(),
7 | });
8 | }, 1000);
9 | };
10 |
--------------------------------------------------------------------------------
/webpack/server.js:
--------------------------------------------------------------------------------
1 | const app = require('express')();
2 | const server = require('http').Server(app); // eslint-disable-line new-cap
3 | const io = require('socket.io')(server);
4 |
5 | const port = 3000;
6 |
7 | /* eslint-disable import/no-extraneous-dependencies, import/newline-after-import */
8 | // Will be fixed when there is a seperate production server.
9 | const webpack = require('webpack');
10 | const webpackMiddleware = require('webpack-dev-middleware');
11 | const webpackHotMiddleware = require('webpack-hot-middleware');
12 | const config = require('./webpack.config.js');
13 | const compiler = webpack(config);
14 | /* eslint-enable */
15 |
16 | const { bindSocket } = require('./utils');
17 | const game = require('./routes/api/game');
18 | const {
19 | onActionHandler,
20 | onGameJoinHandler,
21 | onGameLeaveHandler,
22 | onGameStartHandler,
23 | } = require('./handlers');
24 |
25 | // - Routes -------------------------------------------------------------------/
26 | app.post('/api/game/new', game.generateNewGameId);
27 |
28 | // - Middlewares --------------------------------------------------------------/
29 | app.use(webpackMiddleware(compiler, {
30 | hot: true,
31 | filename: config.output.filename,
32 | publicPath: config.output.publicPath,
33 | stats: {
34 | colors: true,
35 | },
36 | }));
37 | app.use(webpackHotMiddleware(compiler));
38 |
39 | // - Server launch ------------------------------------------------------------/
40 | server.listen(port, 'localhost', (error) => {
41 | io.on('connection', (socket) => {
42 | const bindSocketCurried = bindSocket(io, socket);
43 |
44 | socket.on('gameJoin', bindSocketCurried(onGameJoinHandler));
45 | socket.on('gameLeave', bindSocketCurried(onGameLeaveHandler));
46 | socket.on('gameStart', bindSocketCurried(onGameStartHandler));
47 | socket.on('action', bindSocketCurried(onActionHandler));
48 | });
49 |
50 | /* eslint-disable no-console */
51 | if (error) {
52 | console.error(error);
53 | } else {
54 | console.info(`==> 🌎 Listening on port ${port}. ` +
55 | `Open up http://localhost:${port}/ in your browser.`);
56 | }
57 | /* eslint-enable no-console */
58 | });
59 |
--------------------------------------------------------------------------------
/webpack/utils/index.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk'); // eslint-disable-line import/no-extraneous-dependencies
2 |
3 | exports.colors = {
4 | onAction: 'green',
5 | onGameJoin: 'cyan',
6 | onGameStart: 'magenta',
7 | onGameLeave: 'red',
8 | };
9 |
10 | exports.createLogger = function createLogger(prefix, color) {
11 | return console.log.bind(console, `[${chalk[color](prefix)}]`);
12 | };
13 |
14 | exports.newGame = function newGame(gameId, isStarting) {
15 | return {
16 | gameId,
17 | isStarting,
18 | };
19 | };
20 |
21 | exports.lengthOfRoom = function lengthOfRoom(io, roomName) {
22 | const room = io.sockets.adapter.rooms[roomName];
23 |
24 | if (room) {
25 | return room.length;
26 | }
27 |
28 | return 0;
29 | };
30 |
31 | exports.clientsForRoom = function clientsForRoom(io, roomName) {
32 | const room = io.sockets.adapter.rooms[roomName];
33 |
34 | if (room) {
35 | return Object.keys(room.sockets);
36 | }
37 |
38 | return [];
39 | };
40 |
41 | exports.isClientInRoom = function isClientInRoom(io, roomName, clientId) {
42 | const clients = exports.clientsForRoom(io, roomName);
43 |
44 | return clients.indexOf(clientId) !== -1;
45 | };
46 |
47 | /**
48 | * Binds supplied io and socket as this to given function
49 | * @param {Object} io the io object
50 | * @param {Object} socket the socket object
51 | * @return {Function} function that takes a function argument that will receive the socket binding
52 | */
53 | exports.bindSocket = function bindSocket(io, socket) {
54 | return functionHandler => functionHandler.bind({ io, socket });
55 | };
56 |
--------------------------------------------------------------------------------
/webpack/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 | const autoprefixer = require('autoprefixer'); // eslint-disable-line import/no-extraneous-dependencies
4 | const HtmlWebpackPlugin = require('html-webpack-plugin');
5 |
6 | const paths = {
7 | root: path.join(__dirname, '..'),
8 | src: path.join(__dirname, '..', 'src'),
9 | dist: path.join(__dirname, '..', 'dist'),
10 | };
11 |
12 | module.exports = {
13 | mode: 'development',
14 | context: paths.root,
15 | devtool: 'cheap-eval-source-map',
16 | entry: ['webpack-hot-middleware/client', 'webpack/hot/dev-server', path.join(paths.src, 'app.js')],
17 | output: {
18 | path: paths.dist,
19 | filename: 'bundle.js',
20 | publicPath: '/',
21 | },
22 | resolve: {
23 | modules: ['src', 'node_modules'],
24 | extensions: ['.json', '.js', '.jsx'],
25 | },
26 | module: {
27 | rules: [
28 | {
29 | test: /\.js$/,
30 | exclude: /node_modules/,
31 | loader: 'babel-loader',
32 | options: {
33 | cacheDirectory: true,
34 | },
35 | },
36 | {
37 | test: /\.scss$/,
38 | use: [
39 | { loader: 'style-loader', options: { sourceMap: true } },
40 | {
41 | loader: 'css-loader',
42 | options: {
43 | modules: true,
44 | importLoaders: 2,
45 | sourceMap: true,
46 | localIdentName: '[local]___[hash:base64:5]',
47 | },
48 | },
49 | {
50 | loader: 'postcss-loader',
51 | options: {
52 | sourceMap: true,
53 | plugins: () => [autoprefixer({ browsers: ['last 2 versions'] })],
54 | },
55 | },
56 | {
57 | loader: 'sass-loader',
58 | options: {
59 | outputStyle: 'expanded',
60 | sourceMap: true,
61 | },
62 | },
63 | ],
64 | },
65 | ],
66 | },
67 | plugins: [
68 | new HtmlWebpackPlugin({ template: 'src/index.html' }),
69 | new webpack.HotModuleReplacementPlugin(),
70 | new webpack.NoEmitOnErrorsPlugin(),
71 | new webpack.DefinePlugin({
72 | 'process.env.NODE_ENV': JSON.stringify('development'),
73 | }),
74 | ],
75 | };
76 |
--------------------------------------------------------------------------------