├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── __test__
│ ├── App.test.js
│ └── utils.spec.js
├── actions
│ └── index.js
├── components
│ ├── ControlButtons.js
│ ├── DirectionButton.js
│ ├── GameStatusButton.js
│ ├── InfoPanel.js
│ ├── Overlay.js
│ ├── SquareBlock.js
│ ├── TetrisGame.js
│ ├── Tetromino.js
│ ├── Well.js
│ ├── WellGrid.js
│ └── styles
│ │ ├── InfoPanel.css
│ │ ├── InfoPanel.scss
│ │ ├── TetrisGame.css
│ │ ├── TetrisGame.scss
│ │ ├── Well.css
│ │ ├── Well.scss
│ │ ├── colors.css
│ │ ├── colors.scss
│ │ ├── size.css
│ │ └── size.scss
├── constants
│ ├── actionTypes.js
│ ├── gameStatus.js
│ ├── initState.js
│ ├── options.js
│ └── tetromino.js
├── index.js
├── reducers
│ └── root.js
└── utils
│ ├── index.js
│ └── storage.js
└── yarn.lock
/.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
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
19 | package-lock.json
20 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 4
4 | - 6
5 | cache:
6 | directories:
7 | - node_modules
8 | script:
9 | - npm test
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Chang Yan
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 | # Tetris
2 |
3 | [](https://travis-ci.org/cyan33/tetris-redux)
4 | [](https://github.com/facebook/jest)
5 |
6 | ## Overview
7 |
8 | This is an implementation of the famous Tetris game in browser side. Also, it's a good choice to get your hands dirty with React and Redux and their related ecosystem.
9 |
10 | ## Features
11 |
12 | - Bootstrapped with `create-react-app`
13 | - Design the view layer with React.js
14 | - Manage the game state with Redux, and Redux-thunk
15 | - Offline support with localStorage
16 | - Mobile support (todo)
17 |
18 | ## Installation
19 |
20 | Play it online at https://cyan.github.io/tetris/
21 |
22 | Or get the local version with full source code:
23 |
24 | clone the repo and install the dependencies:
25 |
26 | ```
27 | > git clone git@github.com:cyan33/tetris.git && cd ./tetris
28 | > npm install
29 | ```
30 |
31 | run:
32 | ```
33 | > npm start
34 | ```
35 | which will automatically direct you to *http://localhost:3000*
36 |
37 | Press the "Start" button and enjoy yourself.
38 |
39 | To run the test,
40 |
41 | ```
42 | > npm test
43 | ```
44 |
45 | ## Demo
46 |
47 | 
48 |
49 | ## More About This Project
50 |
51 | ### How the Code Is Organized
52 |
53 | ```
54 | |-- src
55 | |-- __test__: unit test files
56 | |-- actions: redux actions
57 | |-- reducers: redux reducer
58 | |-- components: react components
59 | |-- styles: css files
60 | |-- constants: game configuration
61 | |-- utils: helper utilities about complex tetrimino-related processings
62 | |-- index.js: entry file
63 | |-- package.json
64 | |-- yarn.lock
65 | |-- README
66 | ```
67 |
68 | ### How the Logic Is Decoupled
69 |
70 | React + Redux kind of completes the `MVC` part in front-end.
71 |
72 | First, Redux is responsible for the M and C part in MVC. You could think of its global store/state as the `Model`, which has all the data related to the game. And reducer is part of the controller, which is responsible for changing the state according to the user event.
73 |
74 | Finally, React is the View layer. It enables you to have a clear component tree.
75 |
76 | ### What I've Noticed Along the Way
77 |
78 | 1. **Mutating data/state** is EVIL! It can cause hard-to-find bug and makes the state difficult to track. And that's also the design philosophy of Redux. The reducer should be pure. So everything related to reducer has to be pure as well, like what you may see in the utils file.
79 |
80 | 2. Be smart when debugging. Less `console.log`, take advantage of the advanced tools, like redux-devtool and Chrome devtool and always keep in mind what you are tracking, what is confusing you. Don't let problems get you lost. Always be aware what you are writing.
81 |
82 | 3. Think more, and then write. If you find yourself constantly modifying the exisiting code, you may stop for a while to think thoughrouly what the best strucure, or the best way is. The time you spent on thinking should be much more than writing.
83 |
84 | 4. Rome is not built in one day. Split the whole project into smaller parts and implement them each. And try to decouple as much as possible.
85 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tetris",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "lodash": "^4.17.4",
7 | "marked": "^0.3.6",
8 | "node-sass-chokidar": "0.0.3",
9 | "npm-run-all": "^4.0.2",
10 | "prop-types": "^15.6.0",
11 | "react": "^15.5.4",
12 | "react-dom": "^15.5.4",
13 | "react-redux": "^5.0.5",
14 | "redux": "^3.7.1",
15 | "redux-thunk": "^2.2.0"
16 | },
17 | "homepage": "http://cyan33.github.io/tetris-redux",
18 | "devDependencies": {
19 | "gh-pages": "^1.0.0",
20 | "react-scripts": "0.9.5"
21 | },
22 | "scripts": {
23 | "start-js": "react-scripts start",
24 | "start": "npm-run-all -p watch-css start-js",
25 | "build": "npm run build-css && react-scripts build",
26 | "build-css": "node-sass-chokidar src/components/styles -o src/components/styles",
27 | "watch-css": "npm run build-css && node-sass-chokidar src/components/styles -o src/components/styles --watch --recursive",
28 | "test": "react-scripts test --env=jsdom",
29 | "eject": "react-scripts eject",
30 | "predeploy": "npm run build",
31 | "deploy": "gh-pages -d build"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cyan33/tetris-redux/58584ac25fe534283bd2a5f2a4d130f47ee0987f/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
If you are new to *React* or *Redux*,
20 | this could probably be a good choice where you could get your
21 | hands dirty. So do not forget to check the
22 | **[source code](https://github.com/thomasyimgit/Tetris)**.
23 |
Thanks to **[@Rose](https://twitter.com/luosihua)** for
24 | this awesome UI design, without which this wouldn't
25 | come to the realization.
Built by
26 | **[@Chang](https://twitter.com/changyan33)**.`
27 |
--------------------------------------------------------------------------------
/src/constants/tetromino.js:
--------------------------------------------------------------------------------
1 | export const TETROMINOS = ['I', 'O', 'T', 'J', 'L', 'S', 'Z']
2 | export const COLORS = {
3 | I: '#67b0d4',
4 | O: '#f6d42b',
5 | T: '#fcbab8',
6 | J: '#f9b26c',
7 | L: '#7a4084',
8 | S: '#24c4a0',
9 | Z: '#e84138'
10 | }
11 |
12 | export const NEXT_TETRO_COLOR = '#54b5cd'
13 |
14 | // the shape of each tetro
15 | export const SHAPES = {
16 | I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
17 | O: [[1, 1, 0], [1, 1, 0]],
18 | T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
19 | J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
20 | L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
21 | S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
22 | Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]]
23 | }
24 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import root from './reducers/root'
4 | import _ from 'lodash'
5 | import thunk from 'redux-thunk'
6 | import { Provider } from 'react-redux'
7 | import { createStore, applyMiddleware } from 'redux'
8 |
9 | import TetrisGame from './components/TetrisGame'
10 |
11 | const rootStore = createStore(
12 | root,
13 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
14 | applyMiddleware(thunk)
15 | )
16 |
17 | ReactDOM.render(
18 |
19 |
20 | ,
21 | document.getElementById('root')
22 | )
23 |
--------------------------------------------------------------------------------
/src/reducers/root.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import {
3 | GAME_INIT, GAME_START, GAME_PAUSE, GAME_RESUME,
4 | MOVE, ROTATE, DROP,
5 | ENABLE_ACCELERATE, DISABLE_ACCELERATE
6 | } from '../constants/actionTypes'
7 | import { PLAYING, PAUSING, STOPPED } from '../constants/gameStatus'
8 |
9 | import {
10 | getRandomTetromino, getInitTetroPosition,
11 | isPositionAvailable, rotate, fitTetrominoWithinBoundaries,
12 | generateInitState, hasLineToClear, clearLines,
13 | transferTetroGridIntoWell,
14 | clearDropTimeout
15 | } from '../utils'
16 | import { getTetrisStateFromStorage, updateTetrisStateStorage } from '../utils/storage'
17 | import { SHAPES, COLORS } from '../constants/tetromino'
18 | import { DROP_INTERVAL_DEC, DROP_INTERVAL_MIN } from '../constants/options'
19 |
20 | export default function root(state = {}, action) {
21 | let {
22 | score,
23 | linesCleared,
24 | grid,
25 | nextTetromino,
26 | currTetroGrid,
27 | currTetromino,
28 | currTetroPosition,
29 | dropInterval
30 | } = state
31 |
32 | let newPosition
33 |
34 | switch(action.type) {
35 | // the grid of the well is static, and it doesn't
36 | // count the current dropping tetromino
37 | case GAME_INIT:
38 | return getTetrisStateFromStorage() || generateInitState()
39 | case GAME_START:
40 | return generateInitState(true)
41 | case GAME_PAUSE:
42 | return _.assign({}, state, { gameStatus: PAUSING })
43 | case GAME_RESUME:
44 | return _.assign({}, state, { gameStatus: PLAYING })
45 | case MOVE:
46 | // horizontal move
47 | newPosition = {
48 | x: currTetroPosition.x + action.payload,
49 | y: currTetroPosition.y
50 | }
51 |
52 | if (!isPositionAvailable(grid, currTetroGrid, newPosition)) return state
53 |
54 | return _.assign({}, state, {
55 | currTetroPosition: newPosition
56 | })
57 | case ROTATE:
58 | if (currTetromino === 'O') return state
59 | const newTetroGrid = rotate(currTetroGrid)
60 | newPosition = fitTetrominoWithinBoundaries(grid, newTetroGrid, currTetroPosition)
61 |
62 | if (!isPositionAvailable(grid, newTetroGrid, newPosition)) return state
63 |
64 | else return _.assign({}, state, {
65 | currTetroGrid: newTetroGrid,
66 | currTetroPosition: newPosition
67 | })
68 | case DROP:
69 | // get the newPosition
70 | newPosition = _.assign({}, currTetroPosition, {
71 | y: currTetroPosition.y + 1
72 | })
73 |
74 | // drop until it hits something
75 | if (isPositionAvailable(grid, currTetroGrid, newPosition)) {
76 | return updateTetrisStateStorage(_.assign({}, state, { currTetroPosition: newPosition }))
77 | }
78 |
79 | // position is not available => reaches the bottom-most position of the well
80 |
81 | // there is no extra room for the new tetromino, game over
82 | if (currTetroPosition.y < 0) {
83 | clearDropTimeout()
84 | updateTetrisStateStorage(null)
85 | return _.assign({}, state, { gameStatus: STOPPED })
86 | }
87 |
88 | let newGrid = transferTetroGridIntoWell({
89 | grid,
90 | tetroGrid: currTetroGrid,
91 | tetroPosition: currTetroPosition, // not newPosition!!
92 | color: COLORS[currTetromino]
93 | })
94 |
95 | if (hasLineToClear(newGrid)) {
96 | return updateTetrisStateStorage(_.assign({}, state, {
97 | score: score + 10,
98 | linesCleared: linesCleared + 1,
99 | grid: clearLines(newGrid),
100 | currTetromino: nextTetromino,
101 | currTetroGrid: SHAPES[nextTetromino],
102 | currTetroPosition: getInitTetroPosition(nextTetromino),
103 | nextTetromino: getRandomTetromino(),
104 | dropInterval: dropInterval <= DROP_INTERVAL_MIN ? DROP_INTERVAL_MIN : dropInterval - DROP_INTERVAL_DEC
105 | }))
106 | } else {
107 | return updateTetrisStateStorage(_.assign({}, state, {
108 | grid: newGrid,
109 | score: score + 4,
110 | currTetromino: nextTetromino,
111 | currTetroGrid: SHAPES[nextTetromino],
112 | currTetroPosition: getInitTetroPosition(nextTetromino),
113 | nextTetromino: getRandomTetromino()
114 | }))
115 | }
116 |
117 | case ENABLE_ACCELERATE:
118 | return _.assign({}, state, { isAccelerating: true })
119 | case DISABLE_ACCELERATE:
120 | return _.assign({}, state, { isAccelerating: false })
121 | default:
122 | return state
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 |
3 | import { PLAYING, STOPPED } from '../constants/gameStatus'
4 | import { WELL_COL, WELL_ROW, DROP_INTERVAL_DEFAULT } from '../constants/options'
5 | import { TETROMINOS, SHAPES } from '../constants/tetromino'
6 |
7 | export function generateEmptyWellGrid(row = WELL_ROW, col = WELL_COL) {
8 | return _.times(row, () => {
9 | return _.times(col, () => null)
10 | })
11 | }
12 |
13 | export function getRandomTetromino() {
14 | const rand = Math.floor(Math.random() * TETROMINOS.length)
15 | return TETROMINOS[rand]
16 | }
17 |
18 | export function generateInitState(isPlaying = false) {
19 | const hasntStartedState = {
20 | gameStatus: STOPPED,
21 | score: 0,
22 | linesCleared: 0
23 | }
24 |
25 | const currTetromino = getRandomTetromino()
26 | const currTetroGrid = SHAPES[currTetromino]
27 |
28 | return isPlaying === false ?
29 | hasntStartedState : _.assign({}, hasntStartedState, {
30 | gameStatus: PLAYING,
31 | grid: generateEmptyWellGrid(),
32 | nextTetromino: getRandomTetromino(),
33 | currTetromino,
34 | currTetroGrid,
35 | currTetroPosition: getInitTetroPosition(currTetroGrid),
36 | dropInterval: DROP_INTERVAL_DEFAULT,
37 | isAccelerating: false
38 | })
39 | }
40 |
41 | export function createEmptyLine(col = WELL_COL) {
42 | return _.times(col, () => null)
43 | }
44 |
45 | // todo: the init 'y' should be calculated with the
46 | // current position of the tetromino of different shapes(rotate)
47 | export function getInitTetroPosition(currTetroGrid, col = WELL_COL) {
48 | return {
49 | // 'x' is the left-top point of the shape
50 | // to make it align center, we also need to minus half width of the tetromino
51 | x: Math.round(col / 2) - Math.round(currTetroGrid[0].length / 2),
52 | y: -2
53 | }
54 | }
55 |
56 | export function isPositionAvailable(grid, currTetroGrid, newPosition) {
57 | // determine whether if the tetromino crosses the wall
58 | // or overlaps others
59 |
60 | // note that the single block is at the right bottom side of (x,y)
61 | // which is why we use '>' and sometimes '>=' instead
62 |
63 | const wellRow = grid.length
64 | const wellCol = grid[0].length
65 | const rows = currTetroGrid.length
66 | const cols = currTetroGrid[0].length
67 |
68 | let relativeX
69 | let relativeY
70 |
71 | for (let row = 0; row < rows; row++) {
72 | for (let col = 0; col < cols; col++) {
73 | // skip this loop if the current block is blank
74 | if (!currTetroGrid[row][col]) continue
75 |
76 | relativeX = newPosition.x + col
77 | relativeY = newPosition.y + row
78 |
79 | // boundary check
80 | if (relativeX < 0 || relativeX >= wellCol || relativeY >= wellRow) {
81 | return false
82 | }
83 |
84 | // overlap check
85 | if (relativeY >= 0 && grid[relativeY][relativeX]) {
86 | return false
87 | }
88 | }
89 | }
90 | return true
91 | }
92 |
93 | export function rotate(currTetroGrid) {
94 | // rotate 90 degree clockwise: https://stackoverflow.com/questions/42519/how-do-you-rotate-a-two-dimensional-array
95 | // 1. transpose 2. reverse each row
96 | const rows = currTetroGrid.length
97 | const cols = currTetroGrid[0].length
98 |
99 | let grid = _.times(cols, () => [])
100 | for (let row = 0; row < rows; row++) {
101 | for (let col = 0; col < cols; col++) {
102 | grid[col][row] = currTetroGrid[row][col]
103 | }
104 | }
105 | return grid.map(r => r.reverse())
106 | }
107 |
108 | export function fitTetrominoWithinBoundaries(
109 | grid,
110 | tetrominoGrid,
111 | { x, y }
112 | ) {
113 | // adjust the horizontal position of the tetromino if the rotation makes it out of the boundary
114 | const cols = grid[0].length
115 | let relativeX
116 | let newX = x
117 |
118 | for (let row = 0; row < tetrominoGrid.length; row++) {
119 | for (let col = 0; col < tetrominoGrid[0].length; col++) {
120 | if (!tetrominoGrid[row][col]) continue
121 | relativeX = newX + col
122 |
123 | // todo: I dont think the tetro is able to cross the left boundary??
124 |
125 | // if (relativeX < 0) {
126 | // // newX = 0
127 | // newX++
128 | // }
129 | if (relativeX >= cols) {
130 | // newX -= relativeX - cols + 1
131 | newX--
132 | }
133 | }
134 | }
135 |
136 | return { x: newX, y }
137 | }
138 |
139 | export function hasLineToClear(grid) {
140 | return grid.some(row => {
141 | return row.every(el => el !== null)
142 | })
143 | }
144 |
145 | export function clearLines(grid) {
146 | const emptyRow = _.times(grid[0].length, () => null)
147 |
148 | return grid.reduce((result, row) => {
149 | if (!row.every(el => el !== null)) {
150 | result.push([...row])
151 | } else {
152 | result.unshift(emptyRow)
153 | }
154 | return result
155 | }, [])
156 | }
157 |
158 | export function transferTetroGridIntoWell({ grid, tetroGrid, tetroPosition, color }) {
159 | let newGrid = grid.map(row => row.map(col => col))
160 |
161 | let relativeX, relativeY
162 |
163 | for (let row = 0; row < tetroGrid.length; row++) {
164 | for (let col = 0; col < tetroGrid[0].length; col++) {
165 | if (!tetroGrid[row][col]) continue
166 | relativeX = tetroPosition.x + col
167 | relativeY = tetroPosition.y + row
168 |
169 | newGrid[relativeY][relativeX] = color
170 | }
171 | }
172 | return newGrid
173 | }
174 |
175 | export function setDropTimeout(cb, interval) {
176 | clearDropTimeout()
177 | window.dropTimer = setTimeout(cb, interval)
178 | }
179 |
180 | // bad
181 | export function clearDropTimeout() {
182 | if (!window.dropTimer) return
183 | clearTimeout(window.dropTimer)
184 | window.dropTimer = null
185 | }
186 |
--------------------------------------------------------------------------------
/src/utils/storage.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { PAUSING } from '../constants/gameStatus'
3 |
4 | export function getTetrisStateFromStorage() {
5 | if (localStorage.getItem('tetrisState')) {
6 | return _.assign({}, JSON.parse(localStorage.getItem('tetrisState')), { gameStatus: PAUSING })
7 | }
8 | }
9 |
10 | export function updateTetrisStateStorage(state) {
11 | if (!state) {
12 | localStorage.removeItem('tetrisState')
13 | return
14 | }
15 | localStorage.setItem('tetrisState', JSON.stringify(state))
16 | return state
17 | }
18 |
--------------------------------------------------------------------------------