├── .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 | [![Build Status](https://travis-ci.org/cyan33/tetris-redux.svg?branch=master)](https://travis-ci.org/cyan33/tetris-redux) 4 | [![tested with jest](https://img.shields.io/badge/tested_with-jest-99424f.svg)](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 | ![Tetris_demo](https://i.loli.net/2017/07/20/5970bb6047f79.gif) 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 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 23 | React-Redux-Tetris 24 | 25 | 26 | 29 |
30 |
31 | Fork me on GitHub 32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/__test__/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Well } from '../components/Well'; 4 | // http://cn.redux.js.org/docs/recipes/WritingTests.html 5 | 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render(, div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__test__/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | generateEmptyWellGrid, 3 | isPositionAvailable, 4 | rotate, 5 | fitTetrominoWithinBoundaries, 6 | hasLineToClear, 7 | clearLines, 8 | transferTetroGridIntoWell 9 | } from '../utils' 10 | 11 | import { SHAPES } from '../constants/tetromino' 12 | 13 | let emptyGrid = Array(10).fill([]).map(r => Array(10).fill(null)) 14 | 15 | let grid = [ 16 | [null, null, null, null, null, null, null, null, null, null], 17 | [null, null, null, null, null, null, null, null, null, null], 18 | [null, null, null, null, null, null, null, null, null, null], 19 | [null, null, null, null, null, null, null, null, null, null], 20 | [null, null, null, null, null, null, null, null, null, null], 21 | [null, null, null, null, null, null, null, null, null, null], 22 | [null, null, null, null, null, null, null, null, null, null], 23 | [null, null, null, null, null, null, null, null, null, null], 24 | [null, null, null, null, null, null, null, null, null, null], 25 | [null, null, null, null, null, null, null, null, null, null], 26 | [null, null, null, null, null, null, null, null, null, null], 27 | [null, null, null, null, null, null, null, null, null, null], 28 | [null, null, null, null, null, null, null, null, null, null], 29 | [null, null, null, null, null, null, null, null, null, null], 30 | [null, null, null, null, null, null, null, null, null, null], 31 | [null, null, null,null,null,'#f9b26c',null,null,null,null], 32 | [null,null,'#24c4a0','#24c4a0',null,'#f9b26c','#f9b26c','#f9b26c',null,'#67b0d4'], 33 | [null,'#24c4a0','#24c4a0',null,'#f6d42b','#f6d42b',null,null,null,'#67b0d4'], 34 | [null,'#24c4a0','#24c4a0',null,'#f6d42b','#f6d42b','#f9b26c','#f9b26c','#f9b26c','#67b0d4'], 35 | ['#24c4a0','#24c4a0',null,null,'#67b0d4','#67b0d4','#67b0d4','#67b0d4','#f9b26c','#67b0d4'] 36 | ] 37 | 38 | it('generates an empty grid of 10x10', () => { 39 | expect(generateEmptyWellGrid(10, 10)).toEqual(emptyGrid) 40 | }) 41 | 42 | it('rotates a 2d array', () => { 43 | // rotate: transpose and reverse each row 44 | const rotatedT = [ 45 | [0, 1, 0], 46 | [0, 1, 1], 47 | [0, 1, 0] 48 | ] 49 | const rotatedJ = [ 50 | [0, 1, 1], 51 | [0, 1, 0], 52 | [0, 1, 0] 53 | ] 54 | expect(rotate(SHAPES.T)).toEqual(rotatedT) 55 | expect(rotate(SHAPES.J)).toEqual(rotatedJ) 56 | }) 57 | 58 | it('detects if the next drop position is available', () => { 59 | let currTetrominoGrid = [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]] 60 | let pos1 = { x: 3, y: 14 } 61 | let pos2 = { x: 3, y: 13 } 62 | 63 | expect(isPositionAvailable(grid, currTetrominoGrid, pos1)).toEqual(false) 64 | expect(isPositionAvailable(grid, currTetrominoGrid, pos2)).toEqual(true) 65 | }) 66 | 67 | it('determines whether if there is a full line in the well grid', () => { 68 | expect(hasLineToClear(grid)).toEqual(false) 69 | 70 | let gridWithFullLine = grid.map((row, i) => { 71 | // make a full line at the end of the grid 72 | return i === grid.length - 1 ? Array(grid[0].length).fill('#000') : row 73 | }) 74 | 75 | expect(hasLineToClear(gridWithFullLine)).toEqual(true) 76 | }) 77 | 78 | it('transfer the tetro grid into the well grid', () => { 79 | let tetroGrid = [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]] 80 | let tetroPosition = { x: 2, y: 13} 81 | 82 | let newGrid = [...grid] 83 | newGrid[14] = [null, null, '#aaa', '#aaa', '#aaa', '#aaa', null, null, null, null], 84 | 85 | expect(transferTetroGridIntoWell({ 86 | grid: newGrid, 87 | tetroGrid, 88 | tetroPosition, 89 | color: '#aaa' 90 | })).toEqual(newGrid) 91 | }) 92 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | GAME_INIT, 3 | GAME_START, 4 | GAME_PAUSE, 5 | GAME_STOP, 6 | GAME_RESUME 7 | } from '../constants/actionTypes' 8 | 9 | import { MOVE, ENABLE_ACCELERATE, DISABLE_ACCELERATE, DROP, ROTATE } from '../constants/actionTypes' 10 | import { PLAYING, STOPPED } from '../constants/gameStatus' 11 | import { DROP_INTERVAL_ACCELERATING } from '../constants/options' 12 | import { setDropTimeout, clearDropTimeout } from '../utils' 13 | 14 | export function gameInit() { 15 | clearDropTimeout() 16 | return { 17 | type: GAME_INIT, 18 | } 19 | } 20 | 21 | // thunk 22 | export const drop = () => (dispatch, getState) => { 23 | const { gameStatus, isAccelerating, dropInterval } = getState() 24 | // this is ugly tho 25 | setDropTimeout(() => { 26 | if (gameStatus === STOPPED) return 27 | 28 | if (gameStatus === PLAYING) { 29 | dispatch({ type: DROP }) 30 | } 31 | 32 | dispatch(drop()) 33 | }, isAccelerating ? DROP_INTERVAL_ACCELERATING : dropInterval) 34 | } 35 | 36 | // thunk 37 | export const gameStart = () => (dispatch, getState) => { 38 | dispatch({ type: GAME_START }) 39 | dispatch(drop()) 40 | } 41 | 42 | export function gamePause() { 43 | clearDropTimeout() 44 | return { 45 | type: GAME_PAUSE, 46 | } 47 | } 48 | 49 | export const gameResume = () => (dispatch, getState) => { 50 | dispatch({ type: GAME_RESUME }) 51 | dispatch(drop()) 52 | } 53 | 54 | export function gameStop() { 55 | clearDropTimeout() 56 | return { 57 | type: GAME_STOP, 58 | } 59 | } 60 | 61 | export const moveRight = () => { 62 | return { 63 | type: MOVE, 64 | payload: 1 65 | } 66 | } 67 | 68 | export const moveLeft = () => { 69 | return { 70 | type: MOVE, 71 | payload: -1 72 | } 73 | } 74 | 75 | export const enableAccelerate = () => { 76 | return { 77 | type: ENABLE_ACCELERATE 78 | } 79 | } 80 | 81 | export const disableAccelerate = () => { 82 | return { 83 | type: DISABLE_ACCELERATE 84 | } 85 | } 86 | 87 | export const rotate = () => { 88 | return { 89 | type: ROTATE 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/ControlButtons.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | 5 | import DirectionButton from './DirectionButton' 6 | 7 | import { PLAYING, STOPPED, PAUSING } from '../constants/gameStatus' 8 | import GameStatusButton from './GameStatusButton' 9 | import { 10 | gamePause, gameResume, gameStart, 11 | moveLeft, moveRight, enableAccelerate, disableAccelerate, rotate 12 | } from '../actions' 13 | 14 | class ControlButtons extends Component { 15 | _getPauseButtonProps() { 16 | const { isPlaying, gameStatus, onGamePause, onGameResume } = this.props 17 | const hasStopped = gameStatus === STOPPED 18 | 19 | return { 20 | text: gameStatus === PAUSING ? 'resume' : 'pause', 21 | onClickHandler: isPlaying ? onGamePause : hasStopped ? () => {} : onGameResume 22 | } 23 | } 24 | 25 | _getStartButtonProps() { 26 | const { gameStatus, onGameStart } = this.props 27 | return { 28 | text: gameStatus !== STOPPED ? 'Restart' : 'Start', 29 | onClickHandler: onGameStart 30 | } 31 | } 32 | 33 | _getDirectionButtonProps(direction) { 34 | const { isPlaying, onMoveLeft, onMoveRight, onRotate, onEnableAccelerate, onDisableAccelerate } = this.props 35 | 36 | if (!isPlaying) return { direction } 37 | 38 | switch(direction) { 39 | case 'left': 40 | case 'right': 41 | return { 42 | direction, 43 | onClickHandler: direction === 'left' ? onMoveLeft : onMoveRight 44 | } 45 | case 'up': 46 | return { 47 | direction, 48 | onClickHandler: onRotate 49 | } 50 | case 'down': 51 | return { 52 | direction, 53 | onMouseDownHandler: onEnableAccelerate, 54 | onMouseUpHandler: onDisableAccelerate 55 | } 56 | default: 57 | return {} 58 | } 59 | } 60 | 61 | render() { 62 | return ( 63 |
64 |
65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | 73 |
74 |
75 | ) 76 | } 77 | } 78 | 79 | function mapStateToProps(state) { 80 | return { 81 | gameStatus: state.gameStatus, 82 | isPlaying: state.gameStatus === PLAYING 83 | } 84 | } 85 | 86 | function mapDispatchToProps(dispatch) { 87 | return { 88 | // onGameInit: () => dispatch(gameInit()), 89 | onGameStart: () => dispatch(gameStart()), 90 | onGamePause: () => dispatch(gamePause()), 91 | onGameResume: () => dispatch(gameResume()), 92 | onMoveLeft: () => dispatch(moveLeft()), 93 | onMoveRight: () => dispatch(moveRight()), 94 | onRotate: () => dispatch(rotate()), 95 | onEnableAccelerate: () => dispatch(enableAccelerate()), 96 | onDisableAccelerate: () => dispatch(disableAccelerate()) 97 | } 98 | } 99 | 100 | ControlButtons.PropTypes = { 101 | gameStatus: PropTypes.string, 102 | isPlaying: PropTypes.bool, 103 | onDisableAccelerate: PropTypes.func, 104 | onEnableAccelerate: PropTypes.func, 105 | onGamePause: PropTypes.func, 106 | onGameResume: PropTypes.func, 107 | onGameStart: PropTypes.func, 108 | onMoveLeft: PropTypes.func, 109 | onMoveRight: PropTypes.func, 110 | onRotate: PropTypes.func 111 | } 112 | 113 | export default connect(mapStateToProps, mapDispatchToProps)(ControlButtons) 114 | -------------------------------------------------------------------------------- /src/components/DirectionButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const DirectionButton = ({ 5 | direction, 6 | onClickHandler, 7 | onMouseDownHandler, 8 | onMouseUpHandler 9 | }) => ( 10 |
11 | {direction === 'up' && 12 | 15 | } 16 | {direction === 'left' && 17 | 20 | } 21 | {direction === 'down' && 22 | 25 | } 26 | {direction === 'right' && 27 | 30 | } 31 |
32 | ) 33 | 34 | DirectionButton.PropTypes = { 35 | direction: PropTypes.string, 36 | onClickHandler: PropTypes.func, 37 | onMouseDownHandler: PropTypes.func, 38 | onMouseUpHandler: PropTypes.func 39 | } 40 | 41 | export default DirectionButton 42 | 43 | -------------------------------------------------------------------------------- /src/components/GameStatusButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const GameStatusButton = ({ 5 | onClickHandler, 6 | text 7 | }) => ( 8 | 11 | ) 12 | 13 | GameStatusButton.PropTypes = { 14 | onClickHandler: PropTypes.func, 15 | text: PropTypes.string 16 | } 17 | 18 | export default GameStatusButton 19 | -------------------------------------------------------------------------------- /src/components/InfoPanel.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import ControlButtons from './ControlButtons' 5 | import Tetromino from './Tetromino' 6 | 7 | import { NEXT_TETRO_COLOR, SHAPES, TETROMINOS } from '../constants/tetromino' 8 | import { STOPPED } from '../constants/gameStatus' 9 | 10 | import './styles/InfoPanel.css' 11 | 12 | class InfoPanel extends Component { 13 | _getTetrominoProps() { 14 | const { nextTetromino } = this.props 15 | return { 16 | color: NEXT_TETRO_COLOR, 17 | tetroGrid: SHAPES[nextTetromino], 18 | isNextTetromino: true 19 | } 20 | } 21 | 22 | render() { 23 | const { score, linesCleared, gameStatus } = this.props 24 | 25 | return ( 26 |
27 |
28 |

Next Tetro

29 | { 30 | gameStatus !== STOPPED && 31 | 32 | } 33 |
34 |
35 |
36 |

Your Score

37 |

{ score }

38 |
39 |
40 |

Lines Cleared

41 |

{ linesCleared }

42 |
43 |
44 | 45 |
46 | ) 47 | } 48 | } 49 | 50 | InfoPanel.PropTypes = { 51 | gameStatus: PropTypes.string, 52 | linesCleared: PropTypes.number, 53 | nextTetromino: PropTypes.oneOf(TETROMINOS), 54 | score: PropTypes.number 55 | } 56 | 57 | export default InfoPanel 58 | -------------------------------------------------------------------------------- /src/components/Overlay.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Overlay = ({ text }) => ( 5 |
6 | ) 7 | 8 | Overlay.PropTypes = { 9 | text: PropTypes.string 10 | } 11 | 12 | export default Overlay 13 | -------------------------------------------------------------------------------- /src/components/SquareBlock.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const SquareBlock = ({ color }) => ( 5 |
6 | ) 7 | 8 | SquareBlock.PropTypes = { 9 | color: PropTypes.string 10 | } 11 | 12 | export default SquareBlock 13 | -------------------------------------------------------------------------------- /src/components/TetrisGame.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | 5 | import Well from './Well' 6 | import InfoPanel from './InfoPanel' 7 | 8 | import { 9 | gameInit, gamePause, gameResume, 10 | moveLeft, moveRight, enableAccelerate, disableAccelerate, rotate 11 | } from '../actions' 12 | import { PLAYING } from '../constants/gameStatus' 13 | import { TETROMINOS } from '../constants/tetromino' 14 | import { UP, LEFT, RIGHT, DOWN } from '../constants/options' 15 | 16 | import './styles/TetrisGame.css' 17 | 18 | // export common class component for test 19 | export class TetrisGame extends Component { 20 | constructor() { 21 | super() 22 | 23 | this._onkeydown = this._onkeydown.bind(this) 24 | this._onkeyup = this._onkeyup.bind(this) 25 | } 26 | 27 | componentDidMount() { 28 | window.addEventListener('keydown', this._onkeydown) 29 | window.addEventListener('keyup', this._onkeyup) 30 | 31 | const { onGameInit } = this.props 32 | onGameInit() 33 | } 34 | 35 | componentWillUnmount() { 36 | window.removeEventListener('keydown', this._onkeydown) 37 | window.removeEventListener('keyup', this._onkeyup) 38 | } 39 | 40 | _onkeydown(e) { 41 | e.preventDefault() 42 | 43 | const { 44 | onMoveLeft, 45 | onMoveRight, 46 | onRotate, 47 | onEnableAccelerate, 48 | isPlaying, 49 | isAccelerating 50 | } = this.props 51 | 52 | if(!isPlaying) return 53 | 54 | switch(e.keyCode) { 55 | case UP: 56 | onRotate() 57 | break 58 | case LEFT: 59 | onMoveLeft() 60 | break 61 | case RIGHT: 62 | onMoveRight() 63 | break 64 | case DOWN: 65 | if (isAccelerating) return 66 | onEnableAccelerate() 67 | break 68 | default: 69 | return 70 | } 71 | } 72 | 73 | _onkeyup(e) { 74 | const { isPlaying, onDisableAccelerate } = this.props 75 | if (!isPlaying) return 76 | 77 | if (e.keyCode === DOWN) { 78 | onDisableAccelerate() 79 | } 80 | } 81 | 82 | _getInfoPanelProps() { 83 | const { score, linesCleared, nextTetromino, gameStatus } = this.props 84 | return { 85 | score, 86 | linesCleared, 87 | nextTetromino, 88 | gameStatus 89 | } 90 | } 91 | 92 | render() { 93 | return ( 94 |
95 | 96 | 97 |
98 | ) 99 | } 100 | } 101 | 102 | function mapStateToProps(state, ownProps) { 103 | return { 104 | gameStatus: state.gameStatus, 105 | score: state.score, 106 | linesCleared: state.linesCleared, 107 | nextTetromino: state.nextTetromino, 108 | isPlaying: state.gameStatus === PLAYING, 109 | isAccelerating: state.isAccelerating, 110 | } 111 | } 112 | 113 | function mapDispatchToProps(dispatch) { 114 | return { 115 | onGameInit: () => dispatch(gameInit()), 116 | onGamePause: () => dispatch(gamePause()), 117 | onGameResume: () => dispatch(gameResume()), 118 | onMoveLeft: () => dispatch(moveLeft()), 119 | onMoveRight: () => dispatch(moveRight()), 120 | onRotate: () => dispatch(rotate()), 121 | onEnableAccelerate: () => dispatch(enableAccelerate()), 122 | onDisableAccelerate: () => dispatch(disableAccelerate()) 123 | } 124 | } 125 | 126 | TetrisGame.PropTypes = { 127 | gameStatus: PropTypes.string, 128 | isAccelerating: PropTypes.bool, 129 | isPlaying: PropTypes.bool, 130 | linesCleared: PropTypes.number, 131 | nextTetromino: PropTypes.oneOf(TETROMINOS), 132 | score: PropTypes.number, 133 | onDisableAccelerate: PropTypes.func, 134 | onEnableAccelerate: PropTypes.func, 135 | onGameInit: PropTypes.func, 136 | onGamePause: PropTypes.func, 137 | onGameResume: PropTypes.func, 138 | onMoveLeft: PropTypes.func, 139 | onMoveRight: PropTypes.func, 140 | onRotate: PropTypes.func 141 | } 142 | 143 | export default connect(mapStateToProps, mapDispatchToProps)(TetrisGame) 144 | -------------------------------------------------------------------------------- /src/components/Tetromino.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import SquareBlock from './SquareBlock' 5 | import { WELL_ROW, WELL_COL } from '../constants/options' 6 | import { SHAPES, TETROMINOS } from '../constants/tetromino' 7 | 8 | export default class Tetromino extends Component { 9 | _getTetrominoUlStyle() { 10 | const { tetroPosition } = this.props 11 | 12 | // todo: remove all redundant grid.length and grid[0].length 13 | // use WELL_ROW and WELL_COL 14 | const rows = WELL_ROW 15 | const cols = WELL_COL 16 | 17 | // for each single block 18 | const widthPercent = 100 / cols 19 | const heightPercent = 100 / rows 20 | 21 | /* 22 | why we use "4" here directly? B/c 4 is the maximum length or width of a tetromino. 23 | Therefore, giving each child-block { width: 25%, height: 25% } and its according { top, left } 24 | wouldn't make any of them overflow 25 | */ 26 | return { 27 | width: `${4 * widthPercent}%`, 28 | height: `${4 * heightPercent}%`, 29 | top: `${tetroPosition.y * heightPercent}%`, 30 | left: `${tetroPosition.x * widthPercent}%` 31 | } 32 | } 33 | 34 | _getNextTetrominoUlStyle() { 35 | const { tetroGrid } = this.props 36 | 37 | return { 38 | width: '65%', 39 | height: '65%', 40 | top: '35%', 41 | // the 4x4 grid doesn't fit well into the nextTetromino panel 42 | // use the hack style for now 43 | left: tetroGrid === SHAPES['I'] ? '18%' : '30%' 44 | } 45 | } 46 | 47 | _renderTetromino() { 48 | const { tetroGrid, color } = this.props 49 | if (!tetroGrid) return 50 | 51 | const rows = tetroGrid.length 52 | const cols = tetroGrid[0].length 53 | let result = [] 54 | 55 | for (let row = 0; row < rows; row++) { 56 | for (let col = 0; col < cols; col++) { 57 | if (!tetroGrid[row][col]) continue 58 | 59 | result.push( 60 |
  • 67 | 68 |
  • 69 | ) 70 | } 71 | } 72 | return result 73 | } 74 | 75 | render() { 76 | const { isNextTetromino } = this.props 77 | return ( 78 | 81 | ) 82 | } 83 | } 84 | 85 | Tetromino.PropTypes = { 86 | currTetroGrid: PropTypes.array, 87 | currTetroPosition: PropTypes.shape({ 88 | x: PropTypes.number, 89 | y: PropTypes.number 90 | }), 91 | currTetromino: PropTypes.oneOf(TETROMINOS), 92 | gameStatus: PropTypes.string, 93 | grid: PropTypes.array 94 | } 95 | -------------------------------------------------------------------------------- /src/components/Well.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | import marked from 'marked' 5 | 6 | import WellGrid from './WellGrid' 7 | import Tetromino from './Tetromino' 8 | import Overlay from './Overlay' 9 | import { COLORS, TETROMINOS } from '../constants/tetromino' 10 | import { PLAYING } from '../constants/gameStatus' 11 | import { GAME_INTRO } from '../constants/options' 12 | 13 | import './styles/Well.css' 14 | 15 | export class Well extends Component { 16 | _getTetrominoProps() { 17 | const { grid, currTetroGrid, currTetroPosition, currTetromino } = this.props 18 | return { 19 | grid, 20 | color: COLORS[currTetromino], 21 | tetroGrid: currTetroGrid, 22 | tetroPosition: currTetroPosition 23 | } 24 | } 25 | 26 | render() { 27 | const { grid, currTetromino, gameStatus } = this.props 28 | return ( 29 |
    30 | 31 | { 32 | currTetromino && 33 | 34 | } 35 | { 36 | gameStatus !== PLAYING && 37 | 38 | } 39 |
    40 | ) 41 | } 42 | } 43 | 44 | Well.PropTypes = { 45 | currTetroGrid: PropTypes.array, 46 | currTetroPosition: PropTypes.shape({ 47 | x: PropTypes.number, 48 | y: PropTypes.number 49 | }), 50 | currTetromino: PropTypes.oneOf(TETROMINOS), 51 | gameStatus: PropTypes.string, 52 | grid: PropTypes.array 53 | } 54 | 55 | function mapStateToProps(state) { 56 | return { 57 | gameStatus: state.gameStatus, 58 | grid: state.grid, 59 | currTetromino: state.currTetromino, 60 | currTetroGrid: state.currTetroGrid, 61 | currTetroPosition: state.currTetroPosition 62 | } 63 | } 64 | 65 | export default connect(mapStateToProps)(Well) 66 | -------------------------------------------------------------------------------- /src/components/WellGrid.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import SquareBlock from './SquareBlock' 5 | 6 | export default class WellGrid extends Component { 7 | shouldComponentUpdate(nextProps) { 8 | return this.props.grid !== nextProps.grid 9 | } 10 | 11 | _renderSquareBlocks() { 12 | const { grid } = this.props 13 | if (!grid) return 14 | 15 | const rows = grid.length 16 | const cols = grid[0].length 17 | 18 | // for each single square block 19 | const widthPercent = 100 / cols 20 | const heightPercent = 100 / rows 21 | 22 | let result = [] 23 | 24 | for (let row = 0; row < rows; row++) { 25 | for (let col = 0; col < cols; col++) { 26 | result.push( 27 |
  • 35 | 36 |
  • 37 | ) 38 | } 39 | } 40 | return result 41 | } 42 | 43 | render() { 44 | return ( 45 | 48 | ) 49 | } 50 | } 51 | 52 | WellGrid.PropTypes = { 53 | grid: PropTypes.array 54 | } 55 | -------------------------------------------------------------------------------- /src/components/styles/InfoPanel.css: -------------------------------------------------------------------------------- 1 | .info-container { 2 | display: flex; 3 | flex-direction: column; 4 | flex: 2; } 5 | .info-container:after { 6 | content: " "; 7 | display: block; 8 | clear: both; } 9 | .info-container .info-title { 10 | text-align: center; 11 | padding-top: 5px; 12 | padding-bottom: 10px; 13 | color: #fff; 14 | font-family: monospace; } 15 | .info-container .info-title.big { 16 | font-size: 33px; } 17 | .info-container .info-title.small { 18 | font-size: 24px; } 19 | .info-container .info-value { 20 | color: #fff; 21 | font-family: monospace; 22 | text-align: center; 23 | font-size: 20px; } 24 | .info-container .next-tetro { 25 | position: relative; 26 | min-height: 220px; 27 | background-color: #a2e2ef; } 28 | @media screen and (max-width: 451px) { 29 | .info-container .next-tetro { 30 | display: none; } } 31 | .info-container .next-tetro .tetromino { 32 | position: absolute; } 33 | .info-container .next-tetro .tetromino .square-block-container { 34 | position: absolute; 35 | width: 25%; 36 | height: 25%; } 37 | .info-container .next-tetro .tetromino .square-block-container .square-block { 38 | width: 100%; 39 | height: 100%; } 40 | .info-container .score { 41 | min-height: 165px; 42 | background-color: #5fd1d7; } 43 | @media screen and (max-width: 451px) { 44 | .info-container .score { 45 | display: none; } } 46 | .info-container .score .section:nth-of-type(1) { 47 | margin-top: 10px; 48 | margin-bottom: 10px; } 49 | .info-container .control-buttons { 50 | flex-basis: 35%; 51 | background-color: #44b4cf; } 52 | .info-container .control-buttons .start-game { 53 | text-align: center; 54 | margin-top: 55px; } 55 | @media screen and (max-width: 451px) { 56 | .info-container .control-buttons .start-game { 57 | margin-top: 10px; } } 58 | .info-container .control-buttons .start-game .gameStatus-btn { 59 | width: 70px; 60 | height: 30px; 61 | cursor: pointer; 62 | color: #fff; 63 | font-weight: 600; 64 | text-transform: capitalize; 65 | border: none; 66 | outline: none; 67 | border-radius: 20px; 68 | background-color: #ef9d9d; } 69 | .info-container .control-buttons .start-game .gameStatus-btn:nth-of-type(1) { 70 | margin-right: 15px; } 71 | .info-container .control-buttons .start-game .gameStatus-btn:active { 72 | background-color: #ef7c7c; } 73 | .info-container .directions-control { 74 | display: flex; 75 | flex-direction: row; 76 | justify-content: center; 77 | margin-top: 40px; } 78 | @media screen and (max-width: 451px) { 79 | .info-container .directions-control { 80 | margin-top: 15px; 81 | padding-bottom: 10px; } } 82 | .info-container .directions-control .d-btn button { 83 | width: 38px; 84 | height: 38px; 85 | cursor: pointer; 86 | font-size: 25px; 87 | color: #216fa7; 88 | background-color: #cdf3bf; 89 | border-radius: 0; } 90 | .info-container .directions-control .d-btn button:not(:nth-of-type(4)) { 91 | border-right: 1px solid #44b4cf; } 92 | .info-container .directions-control .d-btn button:active { 93 | background-color: #b3f59b; } 94 | -------------------------------------------------------------------------------- /src/components/styles/InfoPanel.scss: -------------------------------------------------------------------------------- 1 | @import './colors.scss'; 2 | @import './size.scss'; 3 | 4 | .info-container { 5 | display: flex; 6 | flex-direction: column; 7 | flex: 2; 8 | &:after { 9 | content: " "; 10 | display: block; 11 | clear: both; 12 | } 13 | 14 | .info-title { 15 | text-align: center; 16 | padding-top: 5px; 17 | padding-bottom: 10px; 18 | color: #fff; 19 | font-family: monospace; 20 | &.big { 21 | font-size: 33px; 22 | } 23 | &.small { 24 | font-size: 24px 25 | } 26 | } 27 | 28 | .info-value { 29 | color: #fff; 30 | font-family: monospace; 31 | text-align: center; 32 | font-size: 20px; 33 | } 34 | 35 | .next-tetro { 36 | position: relative; 37 | min-height: 220px; 38 | @media #{$mobile-phone-6} { 39 | display: none; 40 | } 41 | background-color: $light-blue; 42 | .tetromino { 43 | position: absolute; 44 | .square-block-container { 45 | position: absolute; 46 | // Refer to *Tetromino.js* to see why it's 25% 47 | width: 25%; 48 | height: 25%; 49 | .square-block { 50 | width: 100%; 51 | height: 100%; 52 | } 53 | } 54 | } 55 | } 56 | .score { 57 | min-height: 165px; 58 | background-color: $mid-blue; 59 | @media #{$mobile-phone-6} { 60 | display: none; 61 | } 62 | .section:nth-of-type(1) { 63 | margin-top: 10px; 64 | margin-bottom: 10px; 65 | } 66 | } 67 | .control-buttons { 68 | flex-basis: 35%; 69 | background-color: $deep-blue2; 70 | .start-game { 71 | text-align: center; 72 | margin-top: 55px; 73 | @media #{$mobile-phone-6} { 74 | margin-top: 10px; 75 | } 76 | .gameStatus-btn { 77 | width: 70px; 78 | height: 30px; 79 | cursor: pointer; 80 | color: #fff; 81 | font-weight: 600; 82 | text-transform: capitalize; 83 | border: none; 84 | outline: none; 85 | border-radius: 20px; 86 | background-color: $pink; 87 | &:nth-of-type(1) { 88 | margin-right: 15px; 89 | } 90 | &:active { 91 | background-color: $deep-pink; 92 | } 93 | } 94 | } 95 | } 96 | .directions-control { 97 | display: flex; 98 | flex-direction: row; 99 | justify-content: center; 100 | margin-top: 40px; 101 | 102 | @media #{$mobile-phone-6} { 103 | margin-top: 15px; 104 | padding-bottom: 10px; 105 | } 106 | 107 | .d-btn button { 108 | width: 38px; 109 | height: 38px; 110 | cursor: pointer; 111 | font-size: 25px; 112 | color: $deep-blue3; 113 | background-color: $light-green; 114 | border-radius: 0; 115 | &:not(:nth-of-type(4)) { 116 | border-right: 1px solid $deep-blue2; 117 | } 118 | &:active { 119 | background-color: $mid-green; 120 | } 121 | } 122 | } 123 | } 124 | 125 | 126 | -------------------------------------------------------------------------------- /src/components/styles/TetrisGame.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; } 4 | 5 | html, body { 6 | height: 100%; 7 | overflow: hidden; } 8 | 9 | ul li { 10 | list-style: none; } 11 | 12 | button { 13 | outline: none; 14 | border: none; } 15 | 16 | #root { 17 | margin: 0 auto; 18 | overflow: auto; 19 | position: relative; 20 | top: 20px; 21 | width: 530px; 22 | height: 590px; } 23 | @media screen and (max-width: 451px) { 24 | #root { 25 | width: 340px; 26 | height: 100%; 27 | top: 10px; } } 28 | @media screen and (max-width: 321px) { 29 | #root { 30 | width: 260px; 31 | height: 100%; } } 32 | #root .tetris-container { 33 | overflow: hidden; 34 | display: flex; 35 | flex-direction: row; 36 | flex-wrap: nowrap; 37 | align-items: stretch; 38 | width: 100%; 39 | height: 100%; } 40 | @media screen and (max-width: 451px) { 41 | #root .tetris-container { 42 | flex-direction: column; 43 | height: 550px; } } 44 | 45 | .github-fork { 46 | display: block; } 47 | @media screen and (max-width: 751px) { 48 | .github-fork { 49 | display: none; } } 50 | .github-fork img { 51 | position: fixed; 52 | top: 0; 53 | right: 0; 54 | border: 0; } 55 | -------------------------------------------------------------------------------- /src/components/styles/TetrisGame.scss: -------------------------------------------------------------------------------- 1 | @import './size.scss'; 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | html, body { 9 | height: 100%; 10 | overflow: hidden; 11 | } 12 | 13 | ul li { 14 | list-style: none; 15 | } 16 | 17 | button { 18 | outline: none; 19 | border: none; 20 | } 21 | 22 | #root { 23 | margin: 0 auto; 24 | overflow: auto; 25 | position: relative; 26 | top: 20px; 27 | width: 530px; 28 | height: 590px; 29 | @media #{$mobile-phone-6} { 30 | width: 340px; 31 | height: 100%; 32 | top: 10px; 33 | } 34 | @media #{$mobile-phone-5} { 35 | width: 260px; 36 | height: 100%; 37 | } 38 | 39 | .tetris-container { 40 | overflow: hidden; 41 | display: flex; 42 | flex-direction: row; 43 | @media #{$mobile-phone-6} { 44 | flex-direction: column; 45 | height: 550px; 46 | } 47 | flex-wrap: nowrap; 48 | align-items: stretch; 49 | width: 100%; 50 | height: 100%; 51 | } 52 | } 53 | 54 | .github-fork { 55 | display: block; 56 | @media #{$middle-size-screen} { 57 | display: none; 58 | } 59 | 60 | img { 61 | position: fixed; 62 | top: 0; 63 | right: 0; 64 | border: 0; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/styles/Well.css: -------------------------------------------------------------------------------- 1 | .well-container { 2 | position: relative; 3 | flex-basis: 295px; 4 | background-color: #e4faff; } 5 | @media screen and (max-width: 451px) { 6 | .well-container { 7 | flex-basis: 100%; } } 8 | @media screen and (max-width: 321px) { 9 | .well-container { 10 | flex-basis: 440px; } } 11 | .well-container .well-grid { 12 | position: relative; 13 | width: 100%; 14 | height: 100%; } 15 | .well-container .well-grid li.square-block-container { 16 | position: absolute; } 17 | .well-container .well-grid li.square-block-container .square-block { 18 | width: 100%; 19 | height: 100%; } 20 | .well-container .tetromino { 21 | position: absolute; } 22 | .well-container .tetromino .square-block-container { 23 | position: absolute; 24 | width: 25%; 25 | height: 25%; } 26 | .well-container .tetromino .square-block-container .square-block { 27 | width: 100%; 28 | height: 100%; } 29 | .well-container .overlay { 30 | position: absolute; 31 | top: 0; 32 | height: calc(100% - 30px); 33 | color: #0d2e6d; 34 | padding: 15px; 35 | line-height: 2; 36 | overflow: hidden; 37 | background: rgba(228, 250, 255, 0.5); } 38 | .well-container .overlay a { 39 | text-decoration: none; } 40 | -------------------------------------------------------------------------------- /src/components/styles/Well.scss: -------------------------------------------------------------------------------- 1 | @import './colors.scss'; 2 | @import './size.scss'; 3 | 4 | .well-container { 5 | position: relative; 6 | flex-basis: 295px; 7 | @media #{$mobile-phone-6} { 8 | flex-basis: 100%; 9 | } 10 | @media #{$mobile-phone-5} { 11 | flex-basis: 440px; 12 | } 13 | 14 | background-color: $transparent-blue; 15 | 16 | .well-grid { 17 | position: relative; 18 | width: 100%; 19 | height: 100%; 20 | li.square-block-container { 21 | position: absolute; 22 | .square-block { 23 | width: 100%; 24 | height: 100%; 25 | } 26 | } 27 | } 28 | 29 | .tetromino { 30 | position: absolute; 31 | .square-block-container { 32 | position: absolute; 33 | // Refer to *Tetromino.js* to see why it's 25% 34 | width: 25%; 35 | height: 25%; 36 | .square-block { 37 | width: 100%; 38 | height: 100%; 39 | } 40 | } 41 | } 42 | 43 | .overlay { 44 | position: absolute; 45 | top: 0; 46 | height: calc(100% - 30px); 47 | color: $deepest-blue; 48 | padding: 15px; 49 | line-height: 2; 50 | overflow: hidden; 51 | background: $transparent-blue-transparent; 52 | a { 53 | text-decoration: none; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/styles/colors.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyan33/tetris-redux/58584ac25fe534283bd2a5f2a4d130f47ee0987f/src/components/styles/colors.css -------------------------------------------------------------------------------- /src/components/styles/colors.scss: -------------------------------------------------------------------------------- 1 | $transparent-blue: #e4faff; 2 | $transparent-blue-transparent: rgba(228, 250, 255, 0.5); 3 | $light-blue: #a2e2ef; 4 | $mid-blue: #5fd1d7; 5 | $deep-blue1: #5485cd; 6 | $deep-blue2: #44b4cf; 7 | $deep-blue3: #216fa7; 8 | $deepest-blue: #0d2e6d; 9 | $light-green: #cdf3bf; 10 | $mid-green: #b3f59b; 11 | $mint: #24c4a0; 12 | $pink: #ef9d9d; 13 | $deep-pink: #ef7c7c; 14 | 15 | $tetro-blue: #67b0d4; 16 | $tetro-purple: #7a4084; 17 | $tetro-yellow: #f6d42b; 18 | $tetro-pink: #fcbab8; 19 | $tetro-orange: #f9b26c; 20 | -------------------------------------------------------------------------------- /src/components/styles/size.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyan33/tetris-redux/58584ac25fe534283bd2a5f2a4d130f47ee0987f/src/components/styles/size.css -------------------------------------------------------------------------------- /src/components/styles/size.scss: -------------------------------------------------------------------------------- 1 | $middle-size-screen: "screen and (max-width: 751px)"; 2 | $mobile-phone-6: "screen and (max-width: 451px)"; 3 | $mobile-phone-5: "screen and (max-width: 321px)"; 4 | -------------------------------------------------------------------------------- /src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | // actions 2 | export const GAME_INIT = 'GAME_INIT' 3 | export const GAME_START = 'GAME_START' 4 | export const GAME_PAUSE = 'GAME_PAUSE' 5 | export const GAME_STOP = 'GAME_STOP' 6 | export const GAME_RESUME = 'GAME_RESUME' 7 | 8 | // movement 9 | export const MOVE = 'MOVE' // HORIZONTAL MOVE 10 | export const ROTATE = 'ROTATE' // UP 11 | export const DROP = 'DROP' 12 | export const ENABLE_ACCELERATE = 'ENABLE_ACCELERATE' // DOWN 13 | export const DISABLE_ACCELERATE = 'DISABLE_ACCELERATE' 14 | 15 | export const DROPFRAME_INC = 'DROPFRAME_INC' 16 | export const DROPFRAME_DEC = 'DROPFRAME_DEC' 17 | 18 | // options 19 | 20 | -------------------------------------------------------------------------------- /src/constants/gameStatus.js: -------------------------------------------------------------------------------- 1 | // status 2 | export const PLAYING = 'PLAYING' 3 | export const PAUSING = 'PAUSING' 4 | export const STOPPED = 'STOPPED' -------------------------------------------------------------------------------- /src/constants/initState.js: -------------------------------------------------------------------------------- 1 | // the initial state tree object 2 | 3 | import { STOPPED } from './gameStatus' 4 | import { generateEmptyWellGrid, getRandomTetrimino } from '../utils' 5 | 6 | export const newGame = { 7 | gameStatus: STOPPED, 8 | score: 0, 9 | linesCleared: 0 10 | // grid: generateEmptyWellGrid(), 11 | // nextTetromino: 'I', 12 | // currTetromino: getRandomTetrimino(), 13 | // currTetroGrid: [ 14 | // [ 0, 0, 0, 0 ], 15 | // [ 1, 1, 1, 1 ], 16 | // [ 0, 0, 0, 0 ], 17 | // [ 0, 0, 0, 0 ] 18 | // ], 19 | // currTetroPosition: { 20 | // "x": 3, 21 | // "y": 6 22 | // }, 23 | // dropInterval: 48, 24 | // isAccelerating: false 25 | } -------------------------------------------------------------------------------- /src/constants/options.js: -------------------------------------------------------------------------------- 1 | // grid 2 | export const WELL_ROW = 20 3 | export const WELL_COL = 10 4 | export const DROP_INTERVAL_DEFAULT = 600 5 | export const DROP_INTERVAL_MIN = 250 6 | export const DROP_INTERVAL_ACCELERATING = 30 7 | export const DROP_INTERVAL_INC = 30 8 | export const DROP_INTERVAL_DEC = 30 9 | export const LINE_CLEAR_BONUS = [100, 200, 300, 400] 10 | 11 | // keys 12 | export const UP = 38 13 | export const LEFT = 37 14 | export const RIGHT = 39 15 | export const DOWN = 40 16 | 17 | export const GAME_INTRO = `This game is a simplified 18 | implementation(about 2K lines of code) of the famous 19 | game **Tetris**.

    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 | --------------------------------------------------------------------------------