├── .vscode
├── settings.json
├── cSpell.json
└── launch.json
├── renovate.json
├── .eslintignore
├── .github
└── quotion.png
├── public
├── favicon.ico
├── favicon.png
├── favicon-maskable.png
├── manifest.json
├── sitemap.xml
└── index.html
├── src
├── assets
│ ├── audio
│ │ ├── move.mp3
│ │ └── popup.mp3
│ ├── images
│ │ ├── github.png
│ │ └── github-white.png
│ ├── svg
│ │ ├── arrow.svg
│ │ ├── undo.svg
│ │ ├── speaker-on.svg
│ │ ├── reset.svg
│ │ └── speaker-off.svg
│ └── styles
│ │ ├── index.scss
│ │ ├── vars.scss
│ │ └── normalize.scss
├── utils
│ ├── helpers.js
│ ├── __tests__
│ │ ├── registerServiceWorker.test.js
│ │ └── mobileEvents.test.js
│ ├── gitalk.js
│ ├── i18n.js
│ ├── mobileEvents.js
│ └── registerServiceWorker.js
├── components
│ ├── Board
│ │ ├── board.scss
│ │ ├── index.js
│ │ └── __tests__
│ │ │ ├── Board.test.js
│ │ │ └── __snapshots__
│ │ │ └── Board.test.js.snap
│ ├── WrapperButton
│ │ ├── wrapperButton.scss
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── WrapperButton.test.js.snap
│ │ │ └── WrapperButton.test.js
│ │ └── index.js
│ ├── Firework
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── Firework.test.js.snap
│ │ │ └── Firework.test.js
│ │ ├── index.js
│ │ └── firework.scss
│ ├── Speaker
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── Speaker.test.js.snap
│ │ │ └── Speaker.test.js
│ │ └── index.js
│ ├── Tips
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── Tips.test.js.snap
│ │ │ └── Tips.test.js
│ │ ├── tips.scss
│ │ └── index.js
│ ├── Comments
│ │ ├── __tests__
│ │ │ ├── Comments.test.js
│ │ │ └── __snapshots__
│ │ │ │ └── Comments.test.js.snap
│ │ └── index.js
│ ├── Modal
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── Modal.test.js.snap
│ │ │ └── Modal.test.js
│ │ ├── index.js
│ │ └── modal.scss
│ ├── Scores
│ │ ├── __tests__
│ │ │ ├── Scores.test.js
│ │ │ └── __snapshots__
│ │ │ │ └── Scores.test.js.snap
│ │ ├── index.js
│ │ └── scores.scss
│ ├── Cell
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── Cell.test.js.snap
│ │ │ └── Cell.test.js
│ │ ├── index.js
│ │ └── cell.scss
│ ├── Button
│ │ ├── __tests__
│ │ │ ├── Button.test.js
│ │ │ └── __snapshots__
│ │ │ │ └── Button.test.js.snap
│ │ ├── index.js
│ │ └── button.scss
│ ├── Footer
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── Footer.test.js.snap
│ │ │ └── Footer.test.js
│ │ ├── footer.scss
│ │ └── index.js
│ └── Row
│ │ ├── index.js
│ │ └── __tests__
│ │ ├── Row.test.js
│ │ └── __snapshots__
│ │ └── Row.test.js.snap
├── containers
│ ├── Ranking
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── Ranking.test.js.snap
│ │ │ └── Ranking.test.js
│ │ ├── ranking.scss
│ │ └── index.js
│ ├── App
│ │ ├── index.js
│ │ ├── __tests__
│ │ │ └── App.test.js
│ │ └── App.js
│ ├── WebApp
│ │ ├── index.js
│ │ ├── webApp.scss
│ │ ├── WebApp.js
│ │ └── __tests__
│ │ │ ├── WebApp.test.js
│ │ │ └── __snapshots__
│ │ │ └── WebApp.test.js.snap
│ ├── ControlPanel
│ │ ├── controlPanel.scss
│ │ ├── index.js
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── ControlPanel.test.js.snap
│ │ │ └── ControlPanel.test.js
│ │ └── ControlPanel.js
│ ├── MobileApp
│ │ ├── mobileApp.scss
│ │ ├── index.js
│ │ ├── __tests__
│ │ │ ├── MobileApp.test.js
│ │ │ └── __snapshots__
│ │ │ │ └── MobileApp.test.js.snap
│ │ └── MobileApp.js
│ └── GameOver
│ │ ├── __tests__
│ │ ├── GameOver.test.js
│ │ └── __snapshots__
│ │ │ └── GameOver.test.js.snap
│ │ ├── gameOver.scss
│ │ └── index.js
├── reducers
│ ├── index.js
│ ├── ranking.js
│ ├── __tests__
│ │ └── board.test.js
│ └── board.js
├── layouts
│ ├── Header
│ │ ├── __tests__
│ │ │ ├── Header.test.js
│ │ │ └── __snapshots__
│ │ │ │ └── Header.test.js.snap
│ │ ├── header.scss
│ │ └── index.js
│ ├── index.js
│ ├── Main
│ │ ├── index.js
│ │ └── __tests__
│ │ │ └── Main.test.js
│ └── __tests__
│ │ └── Layout.test.js
├── index.js
├── store.js
├── apis
│ └── index.js
└── sagas
│ └── index.js
├── internals
├── screenshots
│ ├── pwa.gif
│ ├── web.png
│ ├── i18n.png
│ ├── comments.gif
│ ├── iPhone.png
│ ├── reponsive.gif
│ ├── data-persist.gif
│ └── redux-state.gif
└── img
│ ├── eslint-padded-90.png
│ ├── jest-padded-90.png
│ ├── react-padded-90.png
│ ├── redux-padded-90.png
│ ├── yarn-padded-90.png
│ ├── webpack-padded-90.png
│ ├── redux-saga-padded-90.png
│ └── react-router-padded-90.png
├── scripts
├── deploy.sh
├── test.js
├── start.js
└── build.js
├── .travis.yml
├── .editorconfig
├── .babelrc
├── config
├── jest
│ ├── fileTransform.js
│ └── cssTransform.js
├── polyfills.js
├── paths.js
├── env.js
└── webpackDevServer.config.js
├── .gitignore
├── LICENSE
├── README.md
└── package.json
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": ["codecov", "heroku"]
3 | }
4 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /config/
2 | /scripts/
3 | /public/
4 | /build/
5 | /coverage/
6 |
--------------------------------------------------------------------------------
/.github/quotion.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/.github/quotion.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/src/assets/audio/move.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/src/assets/audio/move.mp3
--------------------------------------------------------------------------------
/src/assets/audio/popup.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/src/assets/audio/popup.mp3
--------------------------------------------------------------------------------
/internals/screenshots/pwa.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/pwa.gif
--------------------------------------------------------------------------------
/internals/screenshots/web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/web.png
--------------------------------------------------------------------------------
/public/favicon-maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/public/favicon-maskable.png
--------------------------------------------------------------------------------
/src/assets/images/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/src/assets/images/github.png
--------------------------------------------------------------------------------
/internals/screenshots/i18n.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/i18n.png
--------------------------------------------------------------------------------
/internals/img/eslint-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/eslint-padded-90.png
--------------------------------------------------------------------------------
/internals/img/jest-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/jest-padded-90.png
--------------------------------------------------------------------------------
/internals/img/react-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/react-padded-90.png
--------------------------------------------------------------------------------
/internals/img/redux-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/redux-padded-90.png
--------------------------------------------------------------------------------
/internals/img/yarn-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/yarn-padded-90.png
--------------------------------------------------------------------------------
/internals/screenshots/comments.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/comments.gif
--------------------------------------------------------------------------------
/internals/screenshots/iPhone.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/iPhone.png
--------------------------------------------------------------------------------
/src/assets/images/github-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/src/assets/images/github-white.png
--------------------------------------------------------------------------------
/internals/img/webpack-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/webpack-padded-90.png
--------------------------------------------------------------------------------
/internals/screenshots/reponsive.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/reponsive.gif
--------------------------------------------------------------------------------
/internals/img/redux-saga-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/redux-saga-padded-90.png
--------------------------------------------------------------------------------
/internals/screenshots/data-persist.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/data-persist.gif
--------------------------------------------------------------------------------
/internals/screenshots/redux-state.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/screenshots/redux-state.gif
--------------------------------------------------------------------------------
/internals/img/react-router-padded-90.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devrsi0n/React-2048-game/HEAD/internals/img/react-router-padded-90.png
--------------------------------------------------------------------------------
/scripts/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo $PWD
4 | npm run build
5 | cd ../re2048
6 | git add .
7 | git commit -m "update frontend"
8 | git push heroku master
9 |
--------------------------------------------------------------------------------
/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | export function isObjEqual(preObj, obj) {
3 | return JSON.stringify(preObj) === JSON.stringify(obj);
4 | }
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '6'
4 | install:
5 | - npm i -g codecov
6 | - yarn
7 | script:
8 | - yarn run ci
9 | - yarn run build
10 | after_script:
11 | - yarn run codecov
12 |
--------------------------------------------------------------------------------
/src/components/Board/board.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .board {
4 | border-collapse: separate;
5 | border-spacing: 0.25vw;
6 | }
7 |
8 | @media all and (max-width: $break-point) {
9 | .board {
10 | border-spacing: 1vw;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | indent_style = space
10 | indent_size = 2
11 | trim_trailing_whitespace = true
12 |
13 | [*.md]
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/src/assets/svg/arrow.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/utils/__tests__/registerServiceWorker.test.js:
--------------------------------------------------------------------------------
1 | import register, { unregister } from '../registerServiceWorker';
2 |
3 | describe('registerServiceWorker.js', () => {
4 | it('not throw', () => {
5 | expect(register).not.toThrow();
6 | expect(unregister).not.toThrow();
7 | });
8 | });
9 |
--------------------------------------------------------------------------------
/src/components/WrapperButton/wrapperButton.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .btn {
4 | margin-right: 0.77vw;
5 | margin-top: 0.77vw;
6 | }
7 |
8 | @media all and (max-width: $break-point) {
9 | .btn {
10 | margin-right: 4vw;
11 | margin-top: 4vw;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/containers/Ranking/__tests__/__snapshots__/Ranking.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
12 | `;
13 |
--------------------------------------------------------------------------------
/src/containers/Ranking/ranking.scss:
--------------------------------------------------------------------------------
1 | .main {
2 | align-items: center;
3 | display: flex;
4 | height: 75vh;
5 | justify-content: center;
6 | margin-top: 1vw;
7 | width: 90vw;
8 |
9 | .echarts {
10 | height: 100%;
11 | padding-left: 5vw;
12 | width: 100%;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Firework/__tests__/__snapshots__/Firework.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
14 | `;
15 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import undoable from 'redux-undo';
3 | import board from './board';
4 | import ranking from './ranking';
5 |
6 | export default combineReducers({
7 | board: undoable(board, {
8 | limit: 11 // set a limit for the history
9 | }),
10 | ranking
11 | });
12 |
--------------------------------------------------------------------------------
/src/utils/__tests__/mobileEvents.test.js:
--------------------------------------------------------------------------------
1 | import swipeDetect from '../mobileEvents';
2 |
3 | describe('mobileEvents.js', () => {
4 | it('not throw', () => {
5 | const el = document.createElement('div');
6 | expect(swipeDetect.bind(Object.create(null), [el, () => {}])).not.toThrow(
7 | 'error'
8 | );
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "env",
5 | {
6 | "modules": false
7 | }
8 | ],
9 | "react",
10 | "stage-2"
11 | ],
12 | "env": {
13 | "test": {
14 | "presets": [
15 | "env",
16 | "stage-2"
17 | ],
18 | "sourceMaps": "inline"
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/Speaker/__tests__/__snapshots__/Speaker.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
13 | `;
14 |
--------------------------------------------------------------------------------
/src/components/Tips/__tests__/__snapshots__/Tips.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
7 |
10 | hello
11 |
12 |
15 | world
16 |
17 |
18 | `;
19 |
--------------------------------------------------------------------------------
/src/components/Comments/__tests__/Comments.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Comments from '..';
4 |
5 | describe('', () => {
6 | it('component render', () => {
7 | const comments = renderer.create().toJSON();
8 | expect(comments).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/assets/svg/undo.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/components/Modal/__tests__/__snapshots__/Modal.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
18 | `;
19 |
--------------------------------------------------------------------------------
/config/jest/fileTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 |
5 | // This is a custom Jest transformer turning file imports into filenames.
6 | // http://facebook.github.io/jest/docs/tutorial-webpack.html
7 |
8 | module.exports = {
9 | process(src, filename) {
10 | return `module.exports = ${JSON.stringify(path.basename(filename))};`;
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/config/jest/cssTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // This is a custom Jest transformer turning style imports into empty objects.
4 | // http://facebook.github.io/jest/docs/tutorial-webpack.html
5 |
6 | module.exports = {
7 | process() {
8 | return 'module.exports = {};';
9 | },
10 | getCacheKey() {
11 | // The output is always the same.
12 | return 'cssTransform';
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/src/components/Scores/__tests__/Scores.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Scores from '..';
4 |
5 | describe('', () => {
6 | it('component render', () => {
7 | const scores = renderer
8 | .create()
9 | .toJSON();
10 | expect(scores).toMatchSnapshot();
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/src/components/WrapperButton/__tests__/__snapshots__/WrapperButton.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
7 |
15 |
16 | `;
17 |
--------------------------------------------------------------------------------
/src/containers/App/index.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { initMatrix } from '../../reducers/board';
3 | import App from './App';
4 |
5 | const mapStateToProps = state => ({
6 | matrix: state.present.board.present.matrix
7 | });
8 |
9 | const mapDispatchToProps = {
10 | onInit: initMatrix
11 | };
12 |
13 | export default connect(mapStateToProps, mapDispatchToProps)(App);
14 |
--------------------------------------------------------------------------------
/src/reducers/ranking.js:
--------------------------------------------------------------------------------
1 | const initState = {
2 | list: []
3 | };
4 |
5 | export default function reducer(state = initState, action) {
6 | switch (action.type) {
7 | case 'SET_RANKING_LIST':
8 | return { ...state, list: action.data };
9 | default:
10 | return state;
11 | }
12 | }
13 |
14 | export const GAMEOVER = 'GAMEOVER';
15 |
16 | export const gameOver = () => ({ type: GAMEOVER });
17 |
--------------------------------------------------------------------------------
/src/containers/Ranking/__tests__/Ranking.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import { Rank } from '../index';
4 |
5 | describe('', () => {
6 | it('component render', () => {
7 | const ranking = renderer
8 | .create( {}} list={[]} />)
9 | .toJSON();
10 | expect(ranking).toMatchSnapshot();
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/src/assets/svg/speaker-on.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/components/Modal/__tests__/Modal.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Modal from '..';
4 |
5 | describe('', () => {
6 | it('component render', () => {
7 | const modal = renderer
8 | .create(
9 |
10 | test
11 |
12 | )
13 | .toJSON();
14 | expect(modal).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | /.idea
24 | /profiles
25 | /monitor
26 | /.vscode/chrome
27 |
--------------------------------------------------------------------------------
/src/assets/svg/reset.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/components/WrapperButton/__tests__/WrapperButton.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import WrapperButton from '..';
4 |
5 | describe('', () => {
6 | it('component render', () => {
7 | const btn = renderer
8 | .create(
9 |
10 | test
11 |
12 | )
13 | .toJSON();
14 | expect(btn).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/Tips/tips.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .tips {
4 | background: $gray;
5 | border-radius: 5px;
6 | color: $black;
7 | margin: 3.85vw auto 0;
8 | text-align: left;
9 | width: 45vw;
10 |
11 | .title {
12 | font-size: 1.25rem;
13 | font-weight: 500;
14 | padding: 1.54vw 0 0.77vw 1.54vw;
15 | }
16 |
17 | .content {
18 | color: $light-black;
19 | font-size: 1.1rem;
20 | padding: 0.23vw 0 1.54vw 1.54vw;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/layouts/Header/__tests__/Header.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import { MemoryRouter } from 'react-router';
4 | import Header from '..';
5 |
6 | describe('', () => {
7 | it('component render', () => {
8 | const header = renderer
9 | .create(
10 |
11 |
12 |
13 | )
14 | .toJSON();
15 | expect(header).toMatchSnapshot();
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React 2048",
3 | "name": "2048 game",
4 | "icons": [
5 | {
6 | "src": "favicon.png",
7 | "sizes": "450x450",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "favicon-maskable.png",
12 | "sizes": "550x550",
13 | "type": "image/png",
14 | "purpose": "maskable"
15 | }
16 | ],
17 | "start_url": "./index.html",
18 | "display": "standalone",
19 | "theme_color": "#000000",
20 | "background_color": "#ffffff"
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/Scores/__tests__/__snapshots__/Scores.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
7 |
10 | SCORE
11 |
14 | 123
15 |
16 |
17 |
20 | BEST
21 |
24 | 456
25 |
26 |
27 |
28 | `;
29 |
--------------------------------------------------------------------------------
/src/components/Firework/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | // import PropTypes from 'prop-types';
3 | import './firework.scss';
4 |
5 | // Display firework animation when game over
6 | export default class Firework extends React.Component {
7 | // Render once, as no props and state
8 | shouldComponentUpdate() {
9 | return false;
10 | }
11 |
12 | render() {
13 | return (
14 |
18 | );
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/containers/WebApp/index.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { reset } from '../../reducers/board';
3 | import WebApp from './WebApp';
4 |
5 | const mapStateToProps = state => ({
6 | matrix: state.present.board.present.matrix,
7 | score: state.present.board.present.score,
8 | bestScore: state.present.board.present.bestScore,
9 | gameOver: state.present.board.present.gameOver
10 | });
11 |
12 | const mapDispatchToProps = {
13 | onReset: reset
14 | };
15 |
16 | export default connect(mapStateToProps, mapDispatchToProps)(WebApp);
17 |
--------------------------------------------------------------------------------
/src/components/Cell/__tests__/__snapshots__/Cell.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
5 |
14 | |
15 | `;
16 |
17 | exports[` component render 2`] = `
18 |
19 |
28 | |
29 | `;
30 |
--------------------------------------------------------------------------------
/src/components/Comments/__tests__/__snapshots__/Comments.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
24 | `;
25 |
--------------------------------------------------------------------------------
/src/layouts/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Header from './Header';
3 | import Main from './Main';
4 | import Footer from '../components/Footer';
5 |
6 | export default function Layouts() {
7 | return (
8 |
9 |
10 |
11 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/containers/WebApp/webApp.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .app {
4 | margin: 2vw auto;
5 | text-align: center;
6 | width: 80vw;
7 | }
8 |
9 | .title {
10 | color: $black;
11 | font-size: 3rem;
12 | margin: 0.67em 0;
13 | }
14 |
15 | .box {
16 | display: flex;
17 | justify-content: center;
18 | }
19 |
20 | .board {
21 | // flex: $flex;
22 | }
23 |
24 | .panel {
25 | align-items: flex-end;
26 | display: flex;
27 | flex-direction: column;
28 | justify-content: space-between;
29 | margin-bottom: 0.5vw;
30 | margin-left: 3vw;
31 | }
32 |
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 | https://re2048.herokuapp.com/
11 | 2017-10-31T02:34:45+00:00
12 | daily
13 |
14 |
--------------------------------------------------------------------------------
/src/assets/svg/speaker-off.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { BrowserRouter } from 'react-router-dom';
5 | import './assets/styles/normalize.scss';
6 | import './assets/styles/index.scss';
7 | import Layouts from './layouts';
8 | import store from './store';
9 | import registerServiceWorker from './utils/registerServiceWorker';
10 |
11 | ReactDOM.render(
12 |
13 |
14 |
15 |
16 | ,
17 | document.getElementById('root')
18 | );
19 |
20 | registerServiceWorker();
21 |
--------------------------------------------------------------------------------
/src/layouts/Main/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Switch, Route } from 'react-router-dom';
3 | import App from '../../containers/App';
4 | import Comments from '../../components/Comments';
5 | import Ranking from '../../containers/Ranking';
6 |
7 | const Main = () => (
8 |
9 |
10 |
11 |
12 |
13 | {/* 404 Page */}
14 |
15 |
16 |
17 | );
18 |
19 | export default Main;
20 |
--------------------------------------------------------------------------------
/src/components/Modal/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import classnames from 'classnames';
4 | import styles from './modal.scss';
5 |
6 | // Modal, parent componet for popup elements
7 | export default function Modal({ display, children }) {
8 | return (
9 |
10 |
11 |
{children}
12 |
13 | );
14 | }
15 |
16 | Modal.propTypes = {
17 | display: PropTypes.bool.isRequired,
18 | children: PropTypes.node.isRequired
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/Button/__tests__/Button.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Button from '..';
4 |
5 | describe('', () => {
6 | it('component render', () => {
7 | let board = renderer.create().toJSON();
8 | expect(board).toMatchSnapshot();
9 |
10 | board = renderer.create().toJSON();
11 | expect(board).toMatchSnapshot();
12 |
13 | board = renderer
14 | .create(
15 |
18 | )
19 | .toJSON();
20 | expect(board).toMatchSnapshot();
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/components/Button/__tests__/__snapshots__/Button.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
10 | `;
11 |
12 | exports[` component render 2`] = `
13 |
19 | `;
20 |
21 | exports[` component render 3`] = `
22 |
30 | `;
31 |
--------------------------------------------------------------------------------
/.vscode/cSpell.json:
--------------------------------------------------------------------------------
1 | {
2 | // cSpell Settings
3 | // cSpell Settings
4 | // Version of the setting file. Always 0.1
5 | "version": "0.1",
6 | // language - current active spelling language
7 | "language": "en",
8 | // words - list of words to be always considered correct
9 | "words": [
10 | "codecov",
11 | "gitment",
12 | "heroku",
13 | "onstatechange",
14 | "onupdatefound",
15 | "Unmount",
16 | "WASD",
17 | "xmlns"
18 | ],
19 | // flagWords - list of words to be always considered incorrect
20 | // This is useful for offensive words and common spelling errors.
21 | // For example "hte" should be "the"
22 | "flagWords": ["hte"]
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/Firework/__tests__/Firework.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Enzyme, { shallow } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import Firework from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | it('component render', () => {
11 | const firework = renderer.create().toJSON();
12 | expect(firework).toMatchSnapshot();
13 | });
14 |
15 | it('shouldComponentUpdate', () => {
16 | const firework = shallow().instance();
17 | expect(firework.shouldComponentUpdate()).toBe(false);
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/src/components/Tips/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from './tips.scss';
4 |
5 | // Game tips, render once enough
6 | export default class Tips extends React.Component {
7 | static propTypes = {
8 | title: PropTypes.string.isRequired,
9 | content: PropTypes.string.isRequired
10 | };
11 |
12 | shouldComponentUpdate() {
13 | return false;
14 | }
15 |
16 | render() {
17 | const { props: { title, content } } = this;
18 |
19 | return (
20 |
21 |
{title}
22 |
{content}
23 |
24 | );
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/containers/ControlPanel/controlPanel.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .panel {
4 | align-items: center;
5 | display: flex;
6 | flex: 1;
7 | flex-direction: column;
8 |
9 | .row-btn {
10 | align-items: center;
11 | display: flex;
12 | flex-grow: 1;
13 | }
14 |
15 | .btn {
16 | margin-right: 0.77vw;
17 | margin-top: 0.77vw;
18 | }
19 |
20 | img {
21 | margin-bottom: -0.35vw;
22 | width: 2.1vw;
23 | }
24 |
25 | .arrows {
26 | display: flex;
27 | flex-direction: row;
28 | }
29 |
30 | .left {
31 | transform: rotate(270deg);
32 | }
33 |
34 | .right {
35 | transform: rotate(90deg);
36 | }
37 |
38 | .down {
39 | transform: rotate(180deg);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Scores/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from './scores.scss';
4 | import i18n from '../../utils/i18n';
5 |
6 | export default function Scores({ score, bestScore }) {
7 | const { index, text, best } = styles;
8 | return (
9 |
10 |
11 | {i18n.score}
12 | {score}
13 |
14 |
15 | {i18n.best}
16 | {bestScore}
17 |
18 |
19 | );
20 | }
21 |
22 | Scores.propTypes = {
23 | score: PropTypes.number.isRequired,
24 | bestScore: PropTypes.number.isRequired
25 | };
26 |
--------------------------------------------------------------------------------
/src/assets/styles/index.scss:
--------------------------------------------------------------------------------
1 | @import './vars';
2 |
3 | // global styles
4 |
5 | :global {
6 | html {
7 | font-size: 1vw;
8 | touch-action: manipulation;
9 | }
10 |
11 | body {
12 | background: #fff;
13 | font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
14 | margin: 0;
15 | padding: 0;
16 | }
17 |
18 | @media all and (max-width: $break-point) {
19 | html {
20 | font-size: 4vw;
21 | }
22 | }
23 |
24 | input,
25 | button,
26 | submit {
27 | border: 0;
28 | }
29 |
30 | h1,
31 | h2,
32 | h3,
33 | h4,
34 | h5,
35 | h6,
36 | p {
37 | margin: 0;
38 | }
39 |
40 | img {
41 | max-width: 100%;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Footer/__tests__/__snapshots__/Footer.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
7 |
10 |
22 | Build with
23 |
26 | ♥
27 |
28 | by
29 |
33 | test
34 |
35 |
36 |
37 | `;
38 |
--------------------------------------------------------------------------------
/src/containers/MobileApp/mobileApp.scss:
--------------------------------------------------------------------------------
1 | .mobile-app {
2 | padding: 8vw 4vw 0;
3 | text-align: center;
4 |
5 | .head {
6 | display: flex;
7 | justify-content: space-around;
8 |
9 | .title {
10 | font-size: 3rem;
11 | // margin-top: -2.5vw;
12 | text-align: left;
13 | }
14 | }
15 |
16 | .row-btn {
17 | align-items: center;
18 | display: flex;
19 | flex-grow: 1;
20 | margin: 3vw 0 6vw;
21 |
22 | .btn {
23 | margin-right: 4vw;
24 | margin-top: 4vw;
25 | }
26 |
27 | img {
28 | margin-bottom: -1vw;
29 | // width: 2.1vw;
30 | }
31 |
32 | .last {
33 | margin-right: -1.5vw;
34 | }
35 | }
36 |
37 | .board {
38 | display: flex;
39 | justify-content: center;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/gitalk.js:
--------------------------------------------------------------------------------
1 | import Gitalk from 'gitalk';
2 |
3 | // Local develop github auth callback url to localhost:300
4 | const config =
5 | process.env.NODE_ENV === 'production'
6 | ? {
7 | clientID: '9b0f4983f59838beb705',
8 | clientSecret: 'b7f0dc0adf6c423ae91f5087a380ea0b0d5cad6a'
9 | }
10 | : {
11 | clientID: '06745677c93217a4268e',
12 | clientSecret: '2cf6d8647b8fc33f9ba29f1edb76720cfea4ee68'
13 | };
14 |
15 | export default new Gitalk({
16 | ...config,
17 | repo: 'React-2048-game',
18 | owner: 'devrsi0n',
19 | admin: ['devrsi0n'],
20 | // facebook-like distraction free mode
21 | distractionFreeMode: false,
22 | createIssueManually: true,
23 | labels: ['Gitalk'],
24 | proxy: 'https://gh-oauth.imsun.net/'
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/Tips/__tests__/Tips.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Enzyme, { shallow } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import Tips from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | it('component render', () => {
11 | let tips = renderer.create().toJSON();
12 | tips = renderer.create().toJSON();
13 | expect(tips).toMatchSnapshot();
14 | });
15 |
16 | it('shouldComponentUpdate', () => {
17 | const tips = shallow().instance();
18 | expect(tips.shouldComponentUpdate()).toBe(false);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/components/WrapperButton/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from './wrapperButton.scss';
4 | import Button from '../../components/Button';
5 |
6 | // Add a wrapper css class for `Button`
7 | export default function WrapperButton({ children, cls, onClick, ...props }) {
8 | return (
9 |
10 | {/* Pass down external props */}
11 |
14 |
15 | );
16 | }
17 |
18 | WrapperButton.propTypes = {
19 | children: PropTypes.node.isRequired,
20 | onClick: PropTypes.func,
21 | cls: PropTypes.string
22 | };
23 |
24 | WrapperButton.defaultProps = {
25 | onClick() {},
26 | cls: ''
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/Modal/modal.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .hidden {
4 | display: none;
5 | }
6 |
7 | .overlay {
8 | background-color: rgba(0, 0, 0, 0.6);
9 | height: 100%;
10 | left: 0;
11 | position: fixed;
12 | top: 0;
13 | width: 100%;
14 | z-index: 10;
15 | }
16 |
17 | .modal {
18 | background-color: transparent;
19 | left: 50%;
20 | margin-left: -15vw;
21 | margin-top: -10vw;
22 | position: fixed;
23 | top: 50%;
24 | z-index: 11; /* 1px higher than the overlay layer */
25 | }
26 |
27 | @media #{$mobile} {
28 | .modal {
29 | margin-left: -37.5vw;
30 | margin-top: -15vw;
31 | }
32 | }
33 |
34 | .center {
35 | align-content: center;
36 | align-items: center;
37 | display: flex;
38 | flex-direction: column;
39 | justify-content: center;
40 | }
41 |
--------------------------------------------------------------------------------
/src/containers/ControlPanel/index.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { ActionCreators } from 'redux-undo';
3 | import {
4 | moveUp,
5 | moveDown,
6 | moveLeft,
7 | moveRight,
8 | placeRandom,
9 | reset
10 | } from '../../reducers/board';
11 | import ControlPanel from './ControlPanel';
12 |
13 | const mapStateToProps = state => ({
14 | isMoved: state.present.board.present.isMoved,
15 | pastLen: state.past.length
16 | });
17 | const mapDispatchToProps = {
18 | onPlaceRandom: placeRandom,
19 | onMoveUp: moveUp,
20 | onMoveDown: moveDown,
21 | onMoveLeft: moveLeft,
22 | onMoveRight: moveRight,
23 | onReset: reset,
24 | onUndo: ActionCreators.jump // Undo move and generated cell
25 | };
26 |
27 | export default connect(mapStateToProps, mapDispatchToProps)(ControlPanel);
28 |
--------------------------------------------------------------------------------
/src/layouts/Main/__tests__/Main.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import { MemoryRouter } from 'react-router';
4 | import { Provider } from 'react-redux';
5 | import Main from '..';
6 | import store from '../../../store';
7 |
8 | // Make random always return 0.5, so snapshot always same.
9 | const mockMath = Object.create(global.Math);
10 | mockMath.random = () => 0.5;
11 | global.Math = mockMath;
12 |
13 | describe('', () => {
14 | it('component render', () => {
15 | const main = renderer
16 | .create(
17 |
18 |
19 |
20 |
21 |
22 | )
23 | .toJSON();
24 | expect(main).toMatchSnapshot();
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/layouts/__tests__/Layout.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import { MemoryRouter } from 'react-router';
4 | import { Provider } from 'react-redux';
5 | import store from '../../store';
6 | import Layout from '..';
7 |
8 | // Make random always return 0.5, so snapshot always same.
9 | const mockMath = Object.create(global.Math);
10 | mockMath.random = () => 0.5;
11 | global.Math = mockMath;
12 |
13 | describe('', () => {
14 | it('component render', () => {
15 | const layout = renderer
16 | .create(
17 |
18 |
19 |
20 |
21 |
22 | )
23 | .toJSON();
24 | expect(layout).toMatchSnapshot();
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/components/Row/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Cell from '../Cell';
4 | import { isObjEqual } from '../../utils/helpers';
5 |
6 | // Game board row, contain 4 cell
7 | export default class Row extends React.Component {
8 | static propTypes = {
9 | row: PropTypes.arrayOf(PropTypes.number).isRequired
10 | };
11 |
12 | shouldComponentUpdate(nextProps, nextState) {
13 | return (
14 | !isObjEqual(nextProps, this.props) || !isObjEqual(nextState, this.state)
15 | );
16 | }
17 |
18 | render() {
19 | const { props: { row } } = this;
20 |
21 | return (
22 |
23 | {/* eslint-disable react/no-array-index-key */}
24 | {row.map((num, idx) => | )}
25 |
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/config/polyfills.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | if (typeof Promise === "undefined") {
4 | // Rejection tracking prevents a common issue where React gets into an
5 | // inconsistent state due to an error, but it gets swallowed by a Promise,
6 | // and the user has no idea what causes React's erratic future behavior.
7 | require("promise/lib/rejection-tracking").enable();
8 | window.Promise = require("promise/lib/es6-extensions.js");
9 | }
10 |
11 | // fetch() polyfill for making API calls.
12 | require("whatwg-fetch");
13 |
14 | // For jest env
15 | if (typeof requestAnimationFrame === 'undefined') {
16 | global.requestAnimationFrame = function(callback) {
17 | setTimeout(callback, 0);
18 | };
19 | }
20 |
21 | // For development and jest env
22 | if (process.env.NODE_ENV !== 'production') {
23 | require('babel-polyfill');
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/Comments/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import gitalk from '../../utils/gitalk';
3 | import styles from './comments.scss';
4 | import i18n from '../../utils/i18n';
5 |
6 | export default class Comments extends Component {
7 | componentDidMount() {
8 | if (process.env.NODE_ENV !== 'test') {
9 | gitalk.render('gitalk-container');
10 | }
11 | }
12 |
13 | render() {
14 | return (
15 |
16 |
17 | {/* eslint-disable react/no-danger */}
18 |
22 |
23 |
24 |
25 | );
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/Speaker/__tests__/Speaker.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Enzyme, { mount } from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
4 | import renderer from 'react-test-renderer';
5 | import Speaker from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | it('component render', () => {
11 | const speaker = renderer.create( {}} />).toJSON();
12 | expect(speaker).toMatchSnapshot();
13 | });
14 |
15 | it('click event', () => {
16 | const spy = jest.fn();
17 | const speaker = mount();
18 | speaker.find('[alt="speaker"]').simulate('click');
19 | expect(spy.mock.calls.length).toBe(1);
20 | speaker.find('[alt="speaker"]').simulate('click');
21 | expect(spy.mock.calls.length).toBe(2);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/assets/styles/vars.scss:
--------------------------------------------------------------------------------
1 | // global scss variables
2 |
3 | $red: #ff5252;
4 | $pink: #e91e63;
5 | $purple: #9c27b0;
6 | $deep-purple: #673ab7;
7 | $indigo: #3f51b5;
8 | $blue: #2196f3;
9 | $light-blue: #03a9f4;
10 | $cyan: #00bcd4;
11 | $teal: #009688;
12 | $green: #4caf50;
13 | $light-green: #8bc34a;
14 | $lime: #cddc39;
15 | $yellow: #ffeb3b;
16 | $amber: #ffc107;
17 | $orange: #ff9800;
18 | $deep-orange: #ff5722;
19 | $brown: #795548;
20 | $grey: #9e9e9e;
21 |
22 | $white: #fff;
23 | $gray: #f7f7f7;
24 | $black: #303030;
25 | $light-black: #666;
26 | $peach: #ff4081;
27 |
28 | $btn-bg: $black;
29 | $btn-color: $white;
30 |
31 | $border-color: #e1e4e8;
32 |
33 | // sizes
34 | $break-point: 768px;
35 | $mobile: 'all and (max-width : #{$break-point})';
36 |
37 |
38 | @function get-vw($object) {
39 | $vw: (1440 * 0.01) * 1px;
40 | @return ($object / $vw) * 1vw;
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Button/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import './button.scss';
4 |
5 | export default function Button({ children, onClick, type, size }) {
6 | const sizeCls = `btn-${size}`;
7 | const typeCls = `btn-${type}`;
8 | return (
9 |
12 | );
13 | }
14 |
15 | Button.propTypes = {
16 | children: PropTypes.oneOfType([PropTypes.node]),
17 | onClick: PropTypes.func,
18 | size: PropTypes.oneOf(['lg', 'md', 'sm', 'xs']),
19 | type: PropTypes.oneOf([
20 | 'default',
21 | 'primary',
22 | 'warn',
23 | 'danger',
24 | 'success',
25 | 'royal'
26 | ])
27 | };
28 |
29 | Button.defaultProps = {
30 | children: '',
31 | onClick() {},
32 | size: 'md',
33 | type: 'default'
34 | };
35 |
--------------------------------------------------------------------------------
/scripts/test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | // Do this as the first thing so that any code reading it knows the right env.
4 | process.env.BABEL_ENV = "test";
5 | process.env.NODE_ENV = "test";
6 | process.env.PUBLIC_URL = "";
7 |
8 | // Makes the script crash on unhandled rejections instead of silently
9 | // ignoring them. In the future, promise rejections that are not handled will
10 | // terminate the Node.js process with a non-zero exit code.
11 | process.on("unhandledRejection", err => {
12 | throw err;
13 | });
14 |
15 | // Ensure environment variables are read.
16 | require("../config/env");
17 |
18 | const jest = require("jest");
19 | const argv = process.argv.slice(2);
20 |
21 | // Watch unless on PRE_PUSH check, CI or in coverage mode
22 | if (
23 | !process.env.PRE_PUSH &&
24 | !process.env.CI &&
25 | argv.indexOf("--coverage") < 0
26 | ) {
27 | argv.push("--watch");
28 | }
29 |
30 | jest.run(argv);
31 |
--------------------------------------------------------------------------------
/src/components/Board/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Row from '../Row';
4 | import styles from './board.scss';
5 | import { isObjEqual } from '../../utils/helpers';
6 |
7 | // Game board
8 | export default class Board extends React.Component {
9 | static propTypes = {
10 | matrix: PropTypes.arrayOf(PropTypes.array).isRequired
11 | };
12 |
13 | shouldComponentUpdate(nextProps, nextState) {
14 | return (
15 | !isObjEqual(nextProps, this.props) || !isObjEqual(nextState, this.state)
16 | );
17 | }
18 |
19 | render() {
20 | const { props: { matrix } } = this;
21 |
22 | return (
23 |
24 |
25 | {/* eslint-disable react/no-array-index-key */}
26 | {matrix.map((row, idx) =>
)}
27 |
28 |
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/Footer/__tests__/Footer.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Enzyme, { shallow } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import Footer from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | it('component render', () => {
11 | const footer = renderer
12 | .create(
13 |
18 | )
19 | .toJSON();
20 | expect(footer).toMatchSnapshot();
21 | });
22 |
23 | it('shouldComponentUpdate', () => {
24 | const footer = shallow(
25 |
30 | ).instance();
31 | expect(footer.shouldComponentUpdate()).toBe(false);
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/components/Footer/footer.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .footer {
4 | border-top: 1px $border-color solid !important;
5 | color: $black;
6 | font-size: 1.25rem;
7 | margin-top: 3.08vw;
8 | width: 100%;
9 |
10 | .container {
11 | align-items: center;
12 | display: flex;
13 | justify-content: center;
14 | margin: 3.08vw 0 2vw;
15 | }
16 |
17 | .icon {
18 | margin-right: 10px;
19 | }
20 |
21 | .github {
22 | width: 32px;
23 | }
24 |
25 | .heart {
26 | color: $peach;
27 | font-size: 1rem;
28 | margin: 0 10px;
29 | transform: scale(0.9);
30 | }
31 |
32 | .link {
33 | margin-left: 10px;
34 | outline: none !important;
35 | text-decoration: none !important;
36 |
37 | &:hover,
38 | &:active,
39 | &:focus {
40 | outline: none !important;
41 | }
42 | }
43 | }
44 |
45 | @media all and (max-width: $break-point) {
46 | .footer {
47 | margin-top: 8vw;
48 | width: 85vw;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/containers/MobileApp/index.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { ActionCreators } from 'redux-undo';
3 | import {
4 | moveUp,
5 | moveDown,
6 | moveLeft,
7 | moveRight,
8 | placeRandom,
9 | reset
10 | } from '../../reducers/board';
11 | import MobileApp from './MobileApp';
12 |
13 | const mapStateToProps = state => ({
14 | matrix: state.present.board.present.matrix,
15 | isMoved: state.present.board.present.isMoved,
16 | pastLen: state.past.length,
17 | score: state.present.board.present.score,
18 | bestScore: state.present.board.present.bestScore,
19 | gameOver: state.present.board.present.gameOver
20 | });
21 |
22 | const mapDispatchToProps = {
23 | onPlaceRandom: placeRandom,
24 | onMoveUp: moveUp,
25 | onMoveDown: moveDown,
26 | onMoveLeft: moveLeft,
27 | onMoveRight: moveRight,
28 | onReset: reset,
29 | onUndo: ActionCreators.jump // Undo move and generated cell
30 | };
31 |
32 | export default connect(mapStateToProps, mapDispatchToProps)(MobileApp);
33 |
--------------------------------------------------------------------------------
/src/components/Cell/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import classnames from 'classnames';
4 | import styles from './cell.scss';
5 | import { isObjEqual } from '../../utils/helpers';
6 |
7 | // Game board cell, minimum component in game board,
8 | // one component stand for a number.
9 | export default class Cell extends React.Component {
10 | static propTypes = {
11 | value: PropTypes.number.isRequired
12 | };
13 |
14 | shouldComponentUpdate(nextProps, nextState) {
15 | return (
16 | !isObjEqual(nextProps, this.props) || !isObjEqual(nextState, this.state)
17 | );
18 | }
19 |
20 | render() {
21 | const { props: { value } } = this;
22 |
23 | const color = `color-${value}`;
24 | return (
25 |
26 |
29 | {value || null}
30 |
31 | |
32 | );
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/containers/GameOver/__tests__/GameOver.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Enzyme, { mount } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import { GameOver } from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | it('component render', () => {
11 | const fn = () => {};
12 | let gameover = renderer
13 | .create()
14 | .toJSON();
15 | expect(gameover).toMatchSnapshot();
16 | gameover = renderer
17 | .create()
18 | .toJSON();
19 | expect(gameover).toMatchSnapshot();
20 | });
21 |
22 | it('mount', () => {
23 | const fn = jest.fn();
24 | const gameover = mount(
25 |
26 | );
27 | gameover.find("[alt='reset']").simulate('click');
28 | expect(fn.mock.calls.length).toBe(1);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/components/Speaker/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import Button from '../Button';
4 | import speakerOn from '../../assets/svg/speaker-on.svg';
5 | import speakerOff from '../../assets/svg/speaker-off.svg';
6 |
7 | // Speaker, keep self state for switch button icon
8 | export default class Speaker extends Component {
9 | static propTypes = {
10 | onClick: PropTypes.func.isRequired
11 | };
12 |
13 | constructor(...args) {
14 | super(...args);
15 |
16 | this.state = {
17 | speaker: speakerOn
18 | };
19 | }
20 |
21 | handleClick = () => {
22 | const { speaker } = this.state;
23 | const prevStatus = speaker === speakerOn;
24 | this.setState({
25 | speaker: prevStatus ? speakerOff : speakerOn
26 | });
27 | this.props.onClick(!prevStatus);
28 | };
29 |
30 | render() {
31 | const { speaker } = this.state;
32 | return (
33 |
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright 2017, devrsion
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/containers/App/__tests__/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import { Provider } from 'react-redux';
4 | import Enzyme, { mount } from 'enzyme';
5 | import Adapter from 'enzyme-adapter-react-16';
6 | import store from '../../../store';
7 | import App from '..';
8 |
9 | Enzyme.configure({ adapter: new Adapter() });
10 |
11 | // Make random always return 0.5, so snapshot always same.
12 | const mockMath = Object.create(global.Math);
13 | mockMath.random = () => 0.5;
14 | global.Math = mockMath;
15 |
16 | describe('', () => {
17 | it('component render', () => {
18 | const app = renderer
19 | .create(
20 |
21 |
22 |
23 | )
24 | .toJSON();
25 | expect(app).toMatchSnapshot();
26 | });
27 |
28 | it('unmount', () => {
29 | const app = mount(
30 |
31 |
32 |
33 | );
34 | window.dispatchEvent(new Event('resize'));
35 | window.innerWidth = 300;
36 | window.dispatchEvent(new Event('resize'));
37 | app.unmount();
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible Node.js debug attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [{
7 | "name": "Chrome",
8 | "type": "chrome",
9 | "request": "launch",
10 | "url": "http://localhost:3000",
11 | "webRoot": "${workspaceRoot}/src",
12 | "userDataDir": "${workspaceRoot}/.vscode/chrome",
13 | "sourceMapPathOverrides": {
14 | "webpack:///src/*": "${webRoot}/*"
15 | }
16 | }, {
17 | "name": "tests",
18 | "type": "node",
19 | "request": "launch",
20 | "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js",
21 | "stopOnEntry": false,
22 | "args": ["--runInBand", "--env=jsdom"],
23 | "cwd": "${workspaceRoot}",
24 | "preLaunchTask": null,
25 | "runtimeExecutable": null,
26 | "runtimeArgs": [
27 | "--nolazy"
28 | ],
29 | "env": {
30 | "NODE_ENV": "development"
31 | },
32 | // "externalConsole": false,
33 | "sourceMaps": false
34 | // "outDir": null
35 | }]
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/Board/__tests__/Board.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Enzyme, { mount } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import Board from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | const MATRIX = [[2, 0, 0, 0], [0, 0, 0, 0], [0, 0, 4, 0], [0, 0, 0, 0]];
10 |
11 | describe('', () => {
12 | it('component render', () => {
13 | const board = renderer.create().toJSON();
14 | expect(board).toMatchSnapshot();
15 | });
16 |
17 | it('shouldComponentUpdate', () => {
18 | const speaker = mount();
19 | expect(speaker.props().matrix).toEqual(MATRIX);
20 | let shouldUpdate = speaker.instance().shouldComponentUpdate(
21 | {
22 | matrix: MATRIX
23 | },
24 | null
25 | );
26 | expect(shouldUpdate).toBe(false);
27 | shouldUpdate = speaker.instance().shouldComponentUpdate(
28 | {
29 | matrix: [[2, 2, 2, 2], [0, 0, 0, 0], [0, 0, 4, 0], [0, 0, 0, 0]]
30 | },
31 | null
32 | );
33 | expect(shouldUpdate).toBe(true);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/components/Cell/__tests__/Cell.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Enzyme, { mount } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import Cell from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | it('component render', () => {
11 | let cell = renderer.create( | ).toJSON();
12 | expect(cell).toMatchSnapshot();
13 |
14 | cell = renderer.create( | ).toJSON();
15 | expect(cell).toMatchSnapshot();
16 | });
17 |
18 | it('shouldComponentUpdate', () => {
19 | const tr = document.createElement('tr');
20 | const cell = mount( | , {
21 | attachTo: tr
22 | });
23 | expect(cell.props().value).toEqual(32);
24 | let shouldUpdate = cell.instance().shouldComponentUpdate(
25 | {
26 | value: 32
27 | },
28 | null
29 | );
30 | expect(shouldUpdate).toBe(false);
31 | shouldUpdate = cell.instance().shouldComponentUpdate(
32 | {
33 | value: 233
34 | },
35 | null
36 | );
37 | expect(shouldUpdate).toBe(true);
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/src/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from './footer.scss';
4 | import github from '../../assets/images/github.png';
5 |
6 | // Footer, link to github repo and developer profile
7 | export default class Footer extends React.Component {
8 | static propTypes = {
9 | name: PropTypes.string.isRequired,
10 | repoUrl: PropTypes.string.isRequired,
11 | profileUrl: PropTypes.string.isRequired
12 | };
13 |
14 | // Render once, no update
15 | shouldComponentUpdate() {
16 | return false;
17 | }
18 |
19 | render() {
20 | const { props: { name, repoUrl, profileUrl } } = this;
21 |
22 | return (
23 |
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Row/__tests__/Row.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import Enzyme, { mount } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import Row from '..';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | it('component render', () => {
11 | const MATRIX = [[2, 0, 0, 0], [0, 0, 0, 0], [0, 0, 4, 0], [0, 0, 0, 0]];
12 | MATRIX.forEach(r => {
13 | const row = renderer.create(
).toJSON();
14 | expect(row).toMatchSnapshot();
15 | });
16 | });
17 |
18 | it('shouldComponentUpdate', () => {
19 | const data = [2, 0, 0, 0];
20 | const tbody = document.createElement('tbody');
21 | const cell = mount(
, {
22 | attachTo: tbody
23 | });
24 | expect(cell.props().row).toEqual(data);
25 | let shouldUpdate = cell.instance().shouldComponentUpdate(
26 | {
27 | row: data
28 | },
29 | null
30 | );
31 | expect(shouldUpdate).toBe(false);
32 | shouldUpdate = cell.instance().shouldComponentUpdate(
33 | {
34 | value: [2, 4, 8, 16]
35 | },
36 | null
37 | );
38 | expect(shouldUpdate).toBe(true);
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 | import undoable from 'redux-undo';
4 | import rootReducer from './reducers';
5 | import rootSaga from './sagas';
6 |
7 | const initHistory = JSON.parse(localStorage.getItem('state') || 'null');
8 |
9 | const sagaMiddleware = createSagaMiddleware();
10 | /* eslint-disable no-underscore-dangle */
11 | const args = [
12 | undoable(rootReducer, {
13 | limit: 11, // set a limit for the history
14 | ignoreInitialState: true
15 | })
16 | ];
17 | // Load localStorage data if available
18 | if (initHistory) {
19 | args.push(initHistory);
20 | }
21 |
22 | if (window.__REDUX_DEVTOOLS_EXTENSION__) {
23 | args.push(
24 | compose(
25 | applyMiddleware(sagaMiddleware),
26 | // Redux devtools necessary code
27 | window.__REDUX_DEVTOOLS_EXTENSION__()
28 | )
29 | );
30 | } else {
31 | args.push(applyMiddleware(sagaMiddleware));
32 | }
33 |
34 | const store = createStore(...args);
35 | sagaMiddleware.run(rootSaga);
36 |
37 | // Call this function while redux state changed,
38 | // this callback save redux state to localStorage
39 | store.subscribe(() => {
40 | const state = store.getState();
41 | localStorage.setItem('state', JSON.stringify(state));
42 | });
43 |
44 | export default store;
45 |
--------------------------------------------------------------------------------
/src/components/Cell/cell.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | $web-len: 6vw;
4 | $mobile-len: 20vw;
5 |
6 | .cell {
7 | align-items: center;
8 | background-color: $black;
9 | border-radius: 5px;
10 | box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.18), 0 1px 5px 0 rgba(0, 0, 0, 0.15);
11 | display: flex;
12 | height: $web-len;
13 | justify-content: center;
14 | width: $web-len;
15 | }
16 |
17 | @media all and (max-width: $break-point) {
18 | .cell {
19 | height: $mobile-len;
20 | width: $mobile-len;
21 | }
22 | }
23 |
24 | .number {
25 | color: $white;
26 | font-size: 2rem;
27 | }
28 |
29 | .color-2 {
30 | background-color: $blue;
31 | }
32 |
33 | .color-4 {
34 | background-color: $green;
35 | }
36 |
37 | .color-8 {
38 | background-color: $purple;
39 | }
40 |
41 | .color-16 {
42 | background-color: $red;
43 | }
44 |
45 | .color-32 {
46 | background-color: $pink;
47 | }
48 |
49 | .color-64 {
50 | background-color: $lime;
51 | }
52 |
53 | .color-128 {
54 | background-color: $indigo;
55 | }
56 |
57 | .color-256 {
58 | background-color: $orange;
59 | }
60 |
61 | .color-512 {
62 | background-color: $deep-purple;
63 | }
64 |
65 | .color-1024 {
66 | background-color: $teal;
67 | }
68 |
69 | .color-2048 {
70 | background-color: $light-blue;
71 | }
72 |
73 | .color-4096 {
74 | background-color: $yellow;
75 | }
76 |
77 | .color-8192 {
78 | background-color: $cyan;
79 | }
80 |
--------------------------------------------------------------------------------
/src/containers/GameOver/gameOver.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | $box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.18), 0 1px 5px 0 rgba(0, 0, 0, 0.15);
4 |
5 | .gameover {
6 | background: #e1f5fe;
7 | border-radius: 10px;
8 | box-shadow: $box-shadow;
9 | height: 10vw;
10 | width: 30vw;
11 |
12 | .text {
13 | color: $blue;
14 | font-size: 1.5rem;
15 | padding: 0.7vw 0;
16 | }
17 |
18 | .banner {
19 | background: $blue;
20 | box-shadow: $box-shadow;
21 | color: #fff;
22 | font-size: 3rem;
23 | height: 5vw;
24 | line-height: 5vw;
25 | margin: 0 -3px;
26 | }
27 | }
28 |
29 | .button-wrapper {
30 | background: #fff;
31 | border: 0;
32 | border-radius: 3vw;
33 | box-shadow: $box-shadow;
34 | flex: 0 0 100%;
35 | margin: auto;
36 | margin-top: 1vw;
37 | width: 80%;
38 |
39 | .button {
40 | background: transparent;
41 | cursor: pointer;
42 | }
43 |
44 | .button:focus {
45 | outline: 0;
46 | }
47 |
48 | img {
49 | padding: 0.5vw;
50 | width: 3vw;
51 | }
52 | }
53 |
54 | @media #{$mobile} {
55 | .gameover {
56 | height: 30vw;
57 | margin: auto;
58 | width: 75vw;
59 |
60 | .text {
61 | font-size: 1rem;
62 | padding: 1.7vw 0;
63 | }
64 |
65 | .banner {
66 | font-size: 2rem;
67 | height: 15vw;
68 | line-height: 15vw;
69 | }
70 | }
71 |
72 | .button-wrapper {
73 | border-radius: 4.7vw;
74 | margin-top: 2.5vw;
75 |
76 | img {
77 | margin-bottom: -1vw;
78 | padding: 1vw;
79 | width: 7vw;
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/Scores/scores.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | .index {
4 | color: $gray;
5 | display: flex;
6 | font-size: 0.75rem;
7 | // font-weight: 400;
8 | justify-content: center;
9 |
10 | .score {
11 | background: transparent;
12 | border-radius: 0;
13 | border-width: 0;
14 | font-weight: 400;
15 | margin: 0;
16 | padding: 1vw 2vw;
17 | position: relative;
18 | z-index: 0;
19 |
20 | &::before {
21 | // background: #fafafa;
22 | background: rgba(29, 137, 255, 0.9);
23 | content: '';
24 | height: 100%;
25 | left: 0;
26 | position: absolute;
27 | top: 0;
28 | transform: skewX(20deg);
29 | width: 100%;
30 | z-index: -1;
31 | }
32 |
33 | &::after {
34 | background: rgba(250, 250, 250, 0.3);
35 | content: '';
36 | height: 100%;
37 | left: 0;
38 | opacity: 0;
39 | position: absolute;
40 | top: 0;
41 | transform: skewX(20deg);
42 | width: 0;
43 | z-index: -1;
44 | }
45 | }
46 |
47 | .text {
48 | color: $white;
49 | display: block;
50 | font-size: 1.25rem;
51 | font-style: normal;
52 | font-weight: 500;
53 | margin-bottom: -0.3vw;
54 | padding-top: 0.35vw;
55 | }
56 |
57 | .best {
58 | margin-left: 1vw;
59 | }
60 |
61 | @media all and (max-width: $break-point) {
62 | .score {
63 | height: 11vw;
64 | padding: 3vw 0 2vw;
65 | width: 20vw;
66 | }
67 |
68 | .text {
69 | padding-top: 1.35vw;
70 | }
71 |
72 | .best {
73 | margin-left: 2vw;
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/layouts/Header/__tests__/__snapshots__/Header.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component render 1`] = `
4 |
69 | `;
70 |
--------------------------------------------------------------------------------
/src/utils/i18n.js:
--------------------------------------------------------------------------------
1 | // i18n text
2 | const data = {
3 | lang: ['cn', 'en'],
4 | default: 'cn',
5 | text: {
6 | title: { cn: 'React 2048', en: 'React 2048' },
7 | home: {
8 | cn: '首页',
9 | en: 'Home'
10 | },
11 | comments: {
12 | cn: '留言',
13 | en: 'Comments'
14 | },
15 | ranking: {
16 | cn: '排行榜',
17 | en: 'Ranking'
18 | },
19 | chartTitle: {
20 | cn: '2048 排行榜',
21 | en: '2048 Ranking list'
22 | },
23 | chartSubTitle: {
24 | cn: '加油冲榜吧! (ง •̀_•́)ง',
25 | en: 'Come on! (ง •̀_•́)ง'
26 | },
27 | score: { cn: '得分', en: 'SCORE' },
28 | best: { cn: '最佳', en: 'BEST' },
29 | tipTitle: { cn: '提示', en: 'Tips' },
30 | tipContent: {
31 | cn:
32 | '使用键盘箭头键(或 WASD )控制方块;反悔了?点回退按钮回退到上一步的状态。想要上排行榜?先去留言面板登录吧。',
33 | en:
34 | 'Use keyboard arrow keys (or `WASD` if you like) control blocks;Click undo button to revert to last step status if you regrets。You score will upload to ranking list after you login at `Comments` panel'
35 | },
36 | commentTitle: {
37 | cn: '欢迎留 tu 言 cao',
38 | en: 'Welcome to leave comments'
39 | }
40 | }
41 | };
42 |
43 | const lang = (() => {
44 | let lan = navigator.language || navigator.userLanguage;
45 | lan = lan === 'zh-CN' ? 'cn' : lan;
46 | lan = lan === 'en-US' ? 'en' : lan;
47 | lan = ['cn', 'en'].includes(lan) ? lan : data.default; // Set default language
48 | return lan;
49 | })();
50 |
51 | const text = {};
52 | Object.keys(data.text).map(key =>
53 | Object.defineProperty(text, [key], {
54 | get: () => data.text[key][lang]
55 | })
56 | );
57 |
58 | export default text;
59 |
--------------------------------------------------------------------------------
/src/apis/index.js:
--------------------------------------------------------------------------------
1 | function* githubGet(url, token) {
2 | return yield fetch(`https://api.github.com${url}`, {
3 | method: 'GET',
4 | headers: {
5 | Authorization: `token ${token}`,
6 | Accept: 'application/vnd.github.v3+json'
7 | },
8 | credentials: 'same-origin'
9 | });
10 | }
11 |
12 | const host = window.location.origin;
13 |
14 | function* serverGet(url) {
15 | return yield fetch(`${host}${url}`, {
16 | method: 'GET',
17 | credentials: 'same-origin'
18 | });
19 | }
20 |
21 | function* serverPut(url, data) {
22 | return yield fetch(`${host}${url}`, {
23 | method: 'PUT',
24 | body: JSON.stringify(data),
25 | headers: {
26 | 'Content-Type': 'application/json'
27 | },
28 | credentials: 'same-origin'
29 | });
30 | }
31 |
32 | const url = '/rank';
33 |
34 | // Ranking list data saved in egg server.
35 | export function* getRankingList() {
36 | let rsp = yield serverGet(url);
37 | rsp = yield rsp.json();
38 | const { list } = rsp;
39 | return list.sort((a, b) => b.score - a.score);
40 | }
41 |
42 | export function* getUserInfo() {
43 | const info = localStorage.getItem('USER_INFO');
44 | if (info && info.name) {
45 | return info;
46 | }
47 |
48 | // Reuse gitalk access token
49 | const token = localStorage.getItem('GT_ACCESS_TOKEN');
50 | if (!token) {
51 | console.log('Must login to upload score');
52 | return null;
53 | }
54 | let rsp = yield githubGet('/user', token);
55 | rsp = yield rsp.json();
56 | console.log(rsp);
57 | if (rsp && rsp.name) {
58 | localStorage.setItem('USER_INFO', JSON.stringify(rsp));
59 | }
60 | return rsp;
61 | }
62 |
63 | export function* updateRankingList(list) {
64 | console.log('list', list);
65 | let rsp = yield serverPut(url, { list });
66 | rsp = yield rsp.json();
67 | return rsp.list.sort((a, b) => b.score - a.score);
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/Firework/firework.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 |
3 | :global {
4 |
5 | $particles: 100;
6 | $width: 500;
7 | $height: 500;
8 |
9 | @media #{$mobile} {
10 | $particles: 30;
11 | }
12 |
13 | // Create the explosion...
14 | $box-shadow: ();
15 | $box-shadow2: ();
16 | @for $i from 0 through $particles {
17 | $box-shadow: $box-shadow,
18 | random($width) - $width / 2 + px
19 | random($height) - $height / 1.2 + px
20 | hsl(random(360), 100, 50);
21 | $box-shadow2: $box-shadow2, 0 0 #fff;
22 | }
23 |
24 | .firework > .before,
25 | .firework > .after {
26 | animation: 1s bang ease-out infinite backwards,
27 | 1s gravity ease-in infinite backwards, 5s position linear infinite backwards;
28 | border-radius: 50%;
29 | box-shadow: $box-shadow2;
30 | height: 5px;
31 | position: absolute;
32 | width: 5px;
33 | }
34 |
35 | .firework > .after {
36 | // animation-delay: 10s, 10s, 10s;
37 | animation-duration: 1.25s, 1.25s, 6.25s;
38 | }
39 |
40 | /* stylelint-disable max-nesting-depth */
41 | @keyframes :global(bang) {
42 | to {
43 | box-shadow: $box-shadow;
44 | }
45 | }
46 |
47 | @keyframes :global(gravity) {
48 | to {
49 | opacity: 0;
50 | transform: translateY(200px);
51 | }
52 | }
53 |
54 | @keyframes :global(position) {
55 | 0%,
56 | 19.9% {
57 | margin-left: 40%;
58 | margin-top: 10%;
59 | }
60 |
61 | 20%,
62 | 39.9% {
63 | margin-left: 30%;
64 | margin-top: 40%;
65 | }
66 |
67 | 40%,
68 | 59.9% {
69 | margin-left: 70%;
70 | margin-top: 20%;
71 | }
72 |
73 | 60%,
74 | 79.9% {
75 | margin-left: 20%;
76 | margin-top: 30%;
77 | }
78 |
79 | 80%,
80 | 99.9% {
81 | margin-left: 80%;
82 | margin-top: 30%;
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/containers/GameOver/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import Modal from '../../components//Modal';
5 | import Firework from '../../components//Firework';
6 | import styles from './gameOver.scss';
7 | import i18n from '../../utils/i18n';
8 | import resetSvg from '../../assets/svg/reset.svg';
9 | import { gameOver as gameover } from '../../reducers/ranking';
10 |
11 | // Game over card
12 | export class GameOver extends React.Component {
13 | componentWillReceiveProps(nextProps) {
14 | if (nextProps.gameOver) {
15 | this.props.onGameOver();
16 | }
17 | }
18 |
19 | render() {
20 | const { props: { gameOver, score, onReset } } = this;
21 |
22 | return (
23 |
24 |
25 | {score > 999 ? : null}
26 |
27 |
28 |
{i18n.score}
29 |
32 |
33 |
34 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 | }
44 |
45 | GameOver.propTypes = {
46 | score: PropTypes.number,
47 | gameOver: PropTypes.bool,
48 | onReset: PropTypes.func,
49 | onGameOver: PropTypes.func
50 | };
51 |
52 | GameOver.defaultProps = {
53 | score: 0,
54 | gameOver: true,
55 | onReset() {},
56 | onGameOver() {}
57 | };
58 |
59 | const mapDispatchToProps = {
60 | onGameOver: gameover
61 | };
62 |
63 | export default (process.env.NODE_ENV !== 'test'
64 | ? connect(null, mapDispatchToProps)(GameOver)
65 | : GameOver);
66 |
--------------------------------------------------------------------------------
/src/layouts/Header/header.scss:
--------------------------------------------------------------------------------
1 | @import '../../assets/styles/vars';
2 | $web-height: 5vw;
3 |
4 | .header {
5 | background-color: #3f51b5;
6 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
7 | color: #fff;
8 | display: flex;
9 | flex-direction: column;
10 | flex-shrink: 0;
11 | flex-wrap: nowrap;
12 | font-size: 1.5rem;
13 | justify-content: flex-start;
14 | margin: 0;
15 | max-height: 50vh;
16 | min-height: $web-height;
17 | padding: 0;
18 | transition-duration: 0.2s;
19 | transition-property: max-height, box-shadow;
20 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
21 | width: 100%;
22 | z-index: 3;
23 |
24 | a {
25 | color: #fff;
26 | text-decoration: none;
27 | }
28 |
29 | li {
30 | list-style-type: none;
31 | }
32 |
33 | .row {
34 | align-items: center;
35 | align-self: stretch;
36 | box-sizing: border-box;
37 | display: flex;
38 | flex-direction: row;
39 | flex-shrink: 0;
40 | flex-wrap: nowrap;
41 | height: $web-height;
42 | margin: 0;
43 | padding: 0 3vw 0 5vw;
44 | }
45 |
46 | .row > * {
47 | flex-shrink: 0;
48 | }
49 |
50 | .title {
51 | box-sizing: border-box;
52 | display: block;
53 | font-weight: 400;
54 | letter-spacing: 0.02em;
55 | line-height: 1;
56 | padding-right: 1.5vw;
57 | position: relative;
58 | }
59 |
60 | .spacer {
61 | flex-grow: 1;
62 | }
63 |
64 | .link {
65 | letter-spacing: 0;
66 | // line-height: 64px;
67 | // opacity: 0.87;
68 | padding: 0 1.5vw;
69 | }
70 |
71 | .active {
72 | color: #ff4081;
73 | }
74 |
75 | .icon {
76 | width: 3vw;
77 | }
78 | }
79 | $mobile-height: 15vw;
80 |
81 | @media #{$mobile} {
82 | .header {
83 | font-size: 1.35rem;
84 | min-height: $mobile-height;
85 |
86 | .row {
87 | min-height: $mobile-height;
88 | }
89 |
90 | .icon {
91 | width: 8vw;
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { put, takeEvery, select, all } from 'redux-saga/effects';
2 | import {
3 | getRankingList,
4 | getUserInfo,
5 | updateRankingList as update
6 | } from '../apis';
7 | import { GAMEOVER } from '../reducers/ranking';
8 |
9 | export function* getThenSetRankingList() {
10 | const rsp = yield getRankingList();
11 | yield put({ type: 'SET_RANKING_LIST', data: rsp });
12 | }
13 |
14 | export function* watchGetRankingList() {
15 | yield takeEvery('GET_RANKLING_LIST', getThenSetRankingList);
16 | }
17 |
18 | const getScore = state => state.present.board.present.score;
19 | const getBestScore = state => state.present.board.present.bestScore;
20 |
21 | export function* updateRankingList() {
22 | const score = yield select(getScore);
23 | const bestScore = yield select(getBestScore);
24 | if (score < bestScore) {
25 | return;
26 | }
27 | const info = yield getUserInfo();
28 | if (!info) {
29 | return;
30 | }
31 | const { email, html_url, name, avatar_url } = info;
32 |
33 | // Get latest list
34 | const list = yield getRankingList();
35 | const newUser = {
36 | email,
37 | profile_url: html_url,
38 | name,
39 | avatar_url,
40 | score
41 | };
42 | const sameOne = list.find(item => item.name === name && item.email === email);
43 | if (sameOne) {
44 | // Update new record
45 | if (score > sameOne.score) sameOne.score = score;
46 | else return;
47 | } else if (list.length < 10) {
48 | // Push to array
49 | list.push(newUser);
50 | } else {
51 | // Insert into array
52 | const idx = list.findIndex(item => score > item.score);
53 | if (idx === -1) return; // Not break record
54 |
55 | list.splice(idx, 0, newUser);
56 | }
57 |
58 | const rsp = yield update(list.slice(0, 10).sort((a, b) => b.score - a.score));
59 | yield put({ type: 'SET_RANKING_LIST', data: rsp });
60 | }
61 |
62 | export function* watchGameOver() {
63 | yield takeEvery(GAMEOVER, updateRankingList);
64 | }
65 |
66 | export default function* rootSaga() {
67 | yield all([watchGetRankingList(), watchGameOver()]);
68 | }
69 |
--------------------------------------------------------------------------------
/src/containers/App/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import WebApp from '../WebApp';
4 | import MobileApp from '../MobileApp';
5 | import moveAudio from '../../assets/audio/move.mp3';
6 | import popupAudio from '../../assets/audio/popup.mp3';
7 | import i18n from '../../utils/i18n';
8 |
9 | // Application entry
10 | export default class App extends Component {
11 | static propTypes = {
12 | matrix: PropTypes.arrayOf(PropTypes.array).isRequired,
13 | onInit: PropTypes.func.isRequired
14 | };
15 |
16 | constructor(...args) {
17 | super(...args);
18 |
19 | this.state = {
20 | isMobile: window.innerWidth <= 768
21 | };
22 | // Control game audio
23 | this.audioMove = new Audio(moveAudio);
24 | this.audioPopup = new Audio(popupAudio);
25 | }
26 |
27 | componentWillMount() {
28 | window.addEventListener('resize', this.mobileDetect, false);
29 | this.boardInit();
30 | }
31 |
32 | componentDidMount() {
33 | document.title = i18n.title;
34 | }
35 |
36 | componentWillUnmount() {
37 | // Never forget remove event after component unmounted,
38 | // avoid memory leak
39 | window.removeEventListener('resize', this.mobileDetect, false);
40 | }
41 |
42 | mobileDetect = () => {
43 | this.setState({
44 | isMobile: window.innerWidth <= 768
45 | });
46 | };
47 |
48 | boardInit() {
49 | let isEmpty = true;
50 | for (const row of this.props.matrix) {
51 | for (const cell of row) {
52 | if (cell > 0) {
53 | isEmpty = false;
54 | break;
55 | }
56 | }
57 | }
58 | // Init empty matrix(add 2 random number to matrix)
59 | if (isEmpty) {
60 | this.props.onInit();
61 | }
62 | }
63 |
64 | render() {
65 | const { isMobile } = this.state;
66 | const { audioMove, audioPopup } = this;
67 | return (
68 |
69 | {isMobile ? (
70 |
71 | ) : (
72 |
73 | )}
74 |
75 | );
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/containers/WebApp/WebApp.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from './webApp.scss';
4 | import Board from '../../components/Board';
5 | import Tips from '../../components/Tips';
6 | import ControlPanel from '../ControlPanel';
7 | import GameOverCard from '../GameOver';
8 | import Scores from '../../components/Scores';
9 | import i18n from '../../utils/i18n';
10 |
11 | // Desktop application entry
12 | export default class WebApp extends Component {
13 | static propTypes = {
14 | matrix: PropTypes.arrayOf(PropTypes.array).isRequired,
15 | audioMove: PropTypes.instanceOf(Audio).isRequired,
16 | audioPopup: PropTypes.instanceOf(Audio).isRequired,
17 | score: PropTypes.number,
18 | bestScore: PropTypes.number,
19 | gameOver: PropTypes.bool,
20 | onReset: PropTypes.func
21 | };
22 |
23 | static defaultProps = {
24 | score: 0,
25 | bestScore: 0,
26 | gameOver: false,
27 | onReset() {}
28 | };
29 |
30 | constructor(...args) {
31 | super(...args);
32 |
33 | // debounce delay in ms
34 | this.delay = process.env.NODE_ENV === 'production' ? 300 : 1;
35 | }
36 |
37 | render() {
38 | const { delay } = this;
39 | const {
40 | matrix,
41 | audioMove,
42 | audioPopup,
43 | bestScore,
44 | score,
45 | gameOver,
46 | onReset
47 | } = this.props;
48 |
49 | return (
50 |
51 |
52 |
53 |
2048
54 |
55 |
56 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/config/paths.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const fs = require('fs');
5 | const url = require('url');
6 |
7 | // Make sure any symlinks in the project folder are resolved:
8 | // https://github.com/facebookincubator/create-react-app/issues/637
9 | const appDirectory = fs.realpathSync(process.cwd());
10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
11 |
12 | const envPublicUrl = process.env.PUBLIC_URL;
13 |
14 | function ensureSlash(path, needsSlash) {
15 | const hasSlash = path.endsWith('/');
16 | if (hasSlash && !needsSlash) {
17 | return path.substr(path, path.length - 1);
18 | } else if (!hasSlash && needsSlash) {
19 | return `${path}/`;
20 | } else {
21 | return path;
22 | }
23 | }
24 |
25 | const getPublicUrl = appPackageJson =>
26 | envPublicUrl || require(appPackageJson).homepage;
27 |
28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer
29 | // "public path" at which the app is served.
30 | // Webpack needs to know it to put the right
7 |
13 |
14 | <% } %>
15 |
16 |
17 |
18 |
19 |
20 |
21 |
25 |
26 |
27 |
28 |
37 | React 2048 game
38 |
39 |
40 |
43 |
44 |
54 | <% if (process.env.NODE_ENV === 'production') { %>
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | <% } %>
63 |
64 |