├── .babelrc ├── .flowconfig ├── .gitignore ├── README.md ├── package.json ├── public ├── index.html └── oberis.gif ├── src ├── actions.js ├── components │ ├── cell.js │ └── stage.js ├── constants.js ├── containers │ └── app.js ├── index.js ├── reducers.js ├── sagas │ ├── control.js │ ├── index.js │ ├── piece.js │ ├── stage.js │ └── timer.js ├── store.js └── utils.js ├── test └── spec │ ├── reducers.js │ └── utils.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", "es2015", "es2016", "stage-2" 4 | ], 5 | "env": { 6 | "development": { 7 | "presets": [ 8 | "power-assert" 9 | ] 10 | }, 11 | "production": { 12 | "plugins": [ 13 | "babel-plugin-unassert" 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [libs] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | build 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oberis ⚡ 2 | 3 | Tetris + [Obelisk.js](https://github.com/nosir/obelisk.js) with Redux. 4 | 5 | ![Oberis Preview][oberis_img] 6 | 7 | ## Getting Started 8 | 9 | ``` 10 | $ npm install 11 | $ npm start 12 | ``` 13 | 14 | Open `http://localhost:8080` in your browser to start game. 15 | 16 | ## Keyboard mapping 17 | 18 | + Move Piece: `UP` / `DOWN` / `RIGHT` / `LEFT` (cursor keys) 19 | + Rotate Piece: `A` (anticlockwise) / `W` (vertical) / `D` (clockwise) *[Comming Soon!]* 20 | + Drop Piece: `Enter` key 21 | + Pause/Resume: `Space` key 22 | 23 | ## Development 24 | 25 | ### Unit Testing (mocha + power-assert) 26 | 27 | ``` 28 | $ npm test 29 | ``` 30 | 31 | ### Static Type Checking (flow) 32 | 33 | ``` 34 | $ npm run flow 35 | ``` 36 | 37 | ## License 38 | 39 | MIT 40 | 41 | ## Author 42 | 43 | Yuki Kodama / [@kuy](https://twitter.com/kuy) 44 | 45 | [oberis_img]: https://github.com/kuy/oberis/blob/master/public/oberis.gif?raw=true 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oberis", 3 | "description": "Tetris + Obelisk.js with Redux.", 4 | "homepage": "https://github.com/kuy/oberis", 5 | "private": "true", 6 | "authors": [ 7 | "Yuki Kodama " 8 | ], 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/kuy/oberis.git" 13 | }, 14 | "scripts": { 15 | "start": "./node_modules/.bin/webpack-dev-server --progress --content-base public/", 16 | "build": "./node_modules/.bin/webpack", 17 | "test": "./node_modules/.bin/mocha --compilers js:babel-register test/spec/*.js", 18 | "flow": "./node_modules/.bin/flow" 19 | }, 20 | "dependencies": { 21 | "obelisk.js": "^1.2.2", 22 | "react": "^15.4.1", 23 | "react-dom": "^15.4.1", 24 | "react-redux": "^4.4.6", 25 | "redux": "^3.6.0", 26 | "redux-actions": "^1.1.0", 27 | "redux-logger": "^2.7.4", 28 | "redux-saga": "^0.13.0" 29 | }, 30 | "devDependencies": { 31 | "babel-cli": "^6.18.0", 32 | "babel-core": "^6.18.2", 33 | "babel-loader": "^6.2.8", 34 | "babel-plugin-unassert": "^2.1.1", 35 | "babel-polyfill": "^6.13.0", 36 | "babel-preset-es2015": "^6.18.0", 37 | "babel-preset-es2016": "^6.16.0", 38 | "babel-preset-power-assert": "^1.0.0", 39 | "babel-preset-react": "^6.11.1", 40 | "babel-preset-stage-2": "^6.18.0", 41 | "babel-register": "^6.18.0", 42 | "flow-bin": "^0.36.0", 43 | "mocha": "^3.2.0", 44 | "power-assert": "^1.4.2", 45 | "webpack": "^2.1.0-beta.25", 46 | "webpack-dev-server": "^2.1.0-beta.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Oberis 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/oberis.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuy/oberis/6e5b19feaadaacde25507b94ff4cd6a31d3e13c8/public/oberis.gif -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions'; 2 | 3 | export const SCORE = 'SCORE'; 4 | export const GAME_OVER = 'GAME_OVER'; 5 | export const RESTART = 'RESTART'; 6 | export const score = createAction(SCORE); 7 | export const gameOver = createAction(GAME_OVER); 8 | export const restart = createAction(RESTART); 9 | 10 | export const TIME_TICK = 'TIME_TICK'; 11 | export const TIME_TOGGLE = 'TIME_TOGGLE'; 12 | export const timeTick = createAction(TIME_TICK); 13 | export const timeToggle = createAction(TIME_TOGGLE); 14 | 15 | export const INPUT_KEY = 'INPUT_KEY'; 16 | export const inputKey = createAction(INPUT_KEY); 17 | 18 | export const PIECE_ADD = 'PIECE_ADD'; 19 | export const PIECE_MOVE = 'PIECE_MOVE'; 20 | export const PIECE_DROP = 'PIECE_DROP'; 21 | export const PIECE_ROTATE = 'PIECE_ROTATE'; 22 | export const PIECE_RASTERIZE = 'PIECE_RASTERIZE'; 23 | export const pieceAdd = createAction(PIECE_ADD); 24 | export const pieceMove = createAction(PIECE_MOVE); 25 | export const pieceDrop = createAction(PIECE_DROP); 26 | export const pieceRotate = createAction(PIECE_ROTATE); 27 | export const pieceRasterize = createAction(PIECE_RASTERIZE); 28 | -------------------------------------------------------------------------------- /src/components/cell.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | function toDOM(state) { 4 | const size = { width: '16', height: '16' }; 5 | if (state === 1) { 6 | return ; 7 | } else { 8 | return ; 9 | } 10 | } 11 | 12 | export default class Cell extends Component { 13 | static get propTypes() { 14 | return { 15 | state: PropTypes.number.isRequired, 16 | }; 17 | } 18 | 19 | render() { 20 | const { state } = this.props; 21 | return toDOM(state); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/stage.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import obelisk from 'obelisk.js'; 3 | import { WIDTH, HEIGHT } from '../constants'; 4 | import { to3D, rasterize, merge } from '../utils'; 5 | 6 | const UNIT = 16; 7 | function scale(list) { 8 | return list.map(n => n * UNIT); 9 | } 10 | 11 | const color = ([dx, dy, dz]) => { 12 | const cache = {}; 13 | return ([x, y, z]) => { 14 | if (typeof cache[z] === 'undefined') { 15 | const unit = z / dz; 16 | const base = Math.ceil(127 * (unit ** 0.75) + 128); 17 | const hori = base * 0x10000 + base * 0x100 + 255; 18 | cache[z] = new obelisk.CubeColor().getByHorizontalColor(hori); 19 | } 20 | return cache[z]; 21 | }; 22 | }; 23 | 24 | const CUBE_DIM = new obelisk.CubeDimension(UNIT, UNIT, UNIT); 25 | function cube(pos, c) { 26 | const box = new obelisk.Cube(CUBE_DIM, c, true); 27 | const point = new obelisk.Point3D(...scale(pos)); 28 | return [box, point]; 29 | } 30 | 31 | const BRICK_COLOR = new obelisk.SideColor().getByInnerColor(obelisk.ColorPattern.GRAY); 32 | function floor([dx, dy, dz]) { 33 | const dim = new obelisk.BrickDimension(dx * UNIT, dy * UNIT); 34 | const brick = new obelisk.Brick(dim, BRICK_COLOR); 35 | return [brick, new obelisk.Point3D(0, 0, 0)]; 36 | } 37 | 38 | export default class Stage extends Component { 39 | componentDidUpdate() { 40 | this.renderObelisk(); 41 | } 42 | 43 | render() { 44 | return ; 45 | } 46 | 47 | renderObelisk() { 48 | if (!this.refs.canvas) { 49 | return; 50 | } 51 | 52 | if (this.refs.canvas && !this.view) { 53 | const point = new obelisk.Point(200, 200); 54 | this.view = new obelisk.PixelView(this.refs.canvas, point); 55 | } else { 56 | this.view.clear(); 57 | } 58 | 59 | const { size } = this.props; 60 | if (!this.color) { 61 | this.color = color(size); 62 | } 63 | 64 | // Draw floor 65 | this.view.renderObject(...floor(size)); 66 | 67 | // Rasterize piece and merge to stage 68 | let stage; 69 | const { data, piece } = this.props; 70 | if (typeof piece.type === 'undefined') { 71 | stage = data; 72 | } else { 73 | const volume = rasterize(size, piece); 74 | stage = merge(data, volume); 75 | } 76 | 77 | // Draw stage 78 | const len = size[0] * size[1] * size[2]; 79 | const translate = to3D.withDim(size); 80 | for (let i = 0; i < len; i++) { 81 | if (stage[i] === 1) { 82 | const pos = translate(i); 83 | this.view.renderObject(...cube(pos, this.color(pos))); 84 | } 85 | } 86 | } 87 | } 88 | 89 | Stage.displayName = 'Stage'; 90 | Stage.propTypes = { 91 | data: PropTypes.array.isRequired, 92 | size: PropTypes.array.isRequired, 93 | piece: PropTypes.object, 94 | }; 95 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const PERIOD = 1000; 2 | export const WIDTH = 600; 3 | export const HEIGHT = 400; 4 | export const DIMENSION = [5, 5, 8]; 5 | -------------------------------------------------------------------------------- /src/containers/app.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Stage from '../components/stage'; 4 | import { restart } from '../actions'; 5 | 6 | class App extends Component { 7 | handleClick() { 8 | this.props.dispatch(restart()); 9 | } 10 | 11 | render() { 12 | const { app, stage, piece } = this.props; 13 | 14 | let message; 15 | if (app.gameOver) { 16 | message =

17 | Game Over 18 | 19 |

; 20 | } 21 | 22 | return
23 | 24 | {message} 25 |
; 26 | } 27 | } 28 | 29 | function select({ app, stage, piece }) { 30 | return { app, stage, piece }; 31 | } 32 | 33 | export default connect(select)(App); 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import App from './containers/app'; 6 | import configureStore from './store'; 7 | 8 | ReactDOM.render( 9 | 10 |
11 |

Oberis

12 | 13 |
14 |
, 15 | document.getElementById('container')); 16 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { 3 | GAME_OVER, SCORE, RESTART, TIME_TICK, 4 | PIECE_ADD, PIECE_MOVE, PIECE_ROTATE, PIECE_RASTERIZE, 5 | } from './actions'; 6 | import { merge, rasterize, empty, isValid, move } from './utils'; 7 | import { DIMENSION } from './constants'; 8 | 9 | export const initial = { 10 | app: { 11 | score: 0, 12 | time: 0, 13 | gameOver: false, 14 | }, 15 | stage: { 16 | size: DIMENSION, 17 | data: [], 18 | }, 19 | piece: { 20 | type: undefined, 21 | position: undefined, 22 | rotate: undefined, 23 | } 24 | }; 25 | 26 | const handlers = { 27 | app: { 28 | [SCORE]: (state, { payload }) => { 29 | return { ...state, score: state.score + payload }; 30 | }, 31 | [GAME_OVER]: (state, { payload }) => { 32 | return { ...state, gameOver: true }; 33 | }, 34 | [RESTART]: (state, { payload }) => { 35 | return { ...initial.app }; 36 | }, 37 | [TIME_TICK]: state => { 38 | return { ...state, time: state.time + 1 }; 39 | }, 40 | }, 41 | stage: { 42 | [TIME_TICK]: state => { 43 | if (isValid(state.size, state.data)) { 44 | return state; 45 | } else { 46 | // TODO: Don't reset stage when resized 47 | return { ...state, data: empty(state.size) }; 48 | } 49 | }, 50 | [PIECE_RASTERIZE]: (state, { payload }) => { 51 | const piece = rasterize(state.size, payload); 52 | return { ...state, data: merge(state.data, piece) }; 53 | }, 54 | [RESTART]: (state, { payload }) => { 55 | return { ...state, data: empty(state.size) }; 56 | }, 57 | }, 58 | piece: { 59 | [PIECE_ADD]: (state, { payload }) => { 60 | return { ...initial.piece, ...payload }; 61 | }, 62 | [PIECE_MOVE]: (state, { payload: dir }) => { 63 | return move(state, dir); 64 | }, 65 | [PIECE_RASTERIZE]: state => { 66 | return { ...initial.piece }; 67 | }, 68 | [RESTART]: state => { 69 | return { ...initial.piece }; 70 | }, 71 | }, 72 | }; 73 | 74 | function createHandler(name) { 75 | return (state = initial[name], action) => { 76 | const handler = handlers[name][action.type]; 77 | if (!handler) { return state; } 78 | return handler(state, action); 79 | }; 80 | } 81 | 82 | export const app = createHandler('app'); 83 | export const stage = createHandler('stage'); 84 | export const piece = createHandler('piece'); 85 | 86 | export default combineReducers( 87 | { app, stage, piece } 88 | ); 89 | -------------------------------------------------------------------------------- /src/sagas/control.js: -------------------------------------------------------------------------------- 1 | import { fork, put } from 'redux-saga/effects'; 2 | import { takeEvery, eventChannel } from 'redux-saga'; 3 | import { inputKey } from '../actions'; 4 | 5 | function createKeyChannel() { 6 | return eventChannel(emit => { 7 | function down(ev) { 8 | emit(ev.key); 9 | } 10 | document.addEventListener('keydown', down, false); 11 | return () => { 12 | document.removeEventListener('keydown', down, false); 13 | }; 14 | }); 15 | } 16 | 17 | function* dispatchKey(name) { 18 | yield put(inputKey(name)); 19 | } 20 | 21 | function* handleKeyInput() { 22 | const key = createKeyChannel(); 23 | yield takeEvery(key, dispatchKey); 24 | } 25 | 26 | export default function* controlSaga() { 27 | yield fork(handleKeyInput); 28 | } 29 | -------------------------------------------------------------------------------- /src/sagas/index.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects'; 2 | import timer from './timer'; 3 | import stage from './stage'; 4 | import piece from './piece'; 5 | import control from './control'; 6 | 7 | export default function* rootSaga() { 8 | yield fork(timer); 9 | yield fork(stage); 10 | yield fork(piece); 11 | yield fork(control); 12 | } 13 | -------------------------------------------------------------------------------- /src/sagas/piece.js: -------------------------------------------------------------------------------- 1 | import { takeEvery } from 'redux-saga'; 2 | import { fork, take, put, select } from 'redux-saga/effects'; 3 | import { INPUT_KEY, TIME_TICK, PIECE_DROP, PIECE_RASTERIZE, pieceAdd, pieceRasterize, pieceMove, pieceDrop } from '../actions'; 4 | import { rand, canMove, rasterize, shrink } from '../utils'; 5 | 6 | function* moveDownByGravity() { 7 | while (true) { 8 | yield take(TIME_TICK); 9 | const [{ data, size }, piece] = yield select(state => [state.stage, state.piece]); 10 | if (typeof piece.type !== 'undefined' && canMove(size, data, piece, 'down')) { 11 | yield put(pieceMove('down')); 12 | } 13 | } 14 | } 15 | 16 | function* moveDownByDrop() { 17 | while (true) { 18 | yield take(PIECE_DROP); 19 | while (true) { 20 | const [{ data, size }, piece] = yield select(state => [state.stage, state.piece]); 21 | if (typeof piece.type !== 'undefined' && canMove(size, data, piece, 'down')) { 22 | yield put(pieceMove('down')); 23 | } else { 24 | break; 25 | } 26 | } 27 | } 28 | } 29 | 30 | function* moveByKeys() { 31 | while (true) { 32 | const { payload: key } = yield take(INPUT_KEY); 33 | const [{ data, size }, piece] = yield select(state => [state.stage, state.piece]); 34 | switch (key) { 35 | case 'ArrowUp': 36 | if (canMove(size, data, piece, 'back')) { 37 | yield put(pieceMove('back')); 38 | } 39 | break; 40 | case 'ArrowRight': 41 | if (canMove(size, data, piece, 'right')) { 42 | yield put(pieceMove('right')); 43 | } 44 | break; 45 | case 'ArrowDown': 46 | if (canMove(size, data, piece, 'front')) { 47 | yield put(pieceMove('front')); 48 | } 49 | break; 50 | case 'ArrowLeft': 51 | if (canMove(size, data, piece, 'left')) { 52 | yield put(pieceMove('left')); 53 | } 54 | break; 55 | case 'Enter': 56 | yield put(pieceDrop()); 57 | break; 58 | default: 59 | console.warn(`Unhandled key: '${key}'`); 60 | } 61 | } 62 | } 63 | 64 | export function* newPiece() { 65 | const dim = yield select(state => state.stage.size); 66 | const type = 1 + rand(5); 67 | const volume = rasterize(dim, { type, position: [0, 0, 0] }); 68 | const [px, py, pz] = shrink(dim, volume); 69 | const [dx, dy, dz] = dim; 70 | const [rx, ry, rz] = [dx - (px - 1), dy - (py - 1), dz - (pz - 1)]; 71 | yield put(pieceAdd({ type, position: [rand(rx), rand(ry), rz] })); 72 | } 73 | 74 | function* spawnNewPiece() { 75 | yield takeEvery(PIECE_RASTERIZE, newPiece); 76 | } 77 | 78 | function* triggerRasterizePiece() { 79 | while (true) { 80 | yield take(TIME_TICK); 81 | const [{ data, size }, piece] = yield select(state => [state.stage, state.piece]); 82 | if (typeof piece.type !== 'undefined') { 83 | if (!canMove(size, data, piece, 'down')) { 84 | yield put(pieceRasterize(piece)); 85 | } 86 | } 87 | } 88 | } 89 | 90 | export default function* pieceSaga() { 91 | yield fork(moveDownByGravity); 92 | yield fork(moveDownByDrop); 93 | yield fork(moveByKeys); 94 | yield fork(triggerRasterizePiece); 95 | yield fork(spawnNewPiece); 96 | 97 | // First piece 98 | yield fork(newPiece); 99 | } 100 | -------------------------------------------------------------------------------- /src/sagas/stage.js: -------------------------------------------------------------------------------- 1 | import { fork, take, put, call } from 'redux-saga/effects'; 2 | import { PIECE_ADD, PIECE_MOVE, PIECE_RASTERIZE, RESTART, timeToggle, gameOver } from '../actions'; 3 | import { newPiece } from './piece'; 4 | 5 | function* triggerGameOver() { 6 | while (true) { 7 | yield take(PIECE_ADD); 8 | const { type } = yield take([PIECE_MOVE, PIECE_RASTERIZE]); 9 | if (type === PIECE_RASTERIZE) { 10 | yield put(timeToggle()); 11 | yield put(gameOver()); 12 | } 13 | } 14 | } 15 | 16 | function* restartStage() { 17 | while (true) { 18 | yield take(RESTART); 19 | yield call(newPiece); 20 | yield put(timeToggle()); 21 | } 22 | } 23 | 24 | export default function* stageSaga() { 25 | yield fork(triggerGameOver); 26 | yield fork(restartStage); 27 | } 28 | -------------------------------------------------------------------------------- /src/sagas/timer.js: -------------------------------------------------------------------------------- 1 | import { eventChannel } from 'redux-saga'; 2 | import { fork, take, put, cancel, cancelled } from 'redux-saga/effects'; 3 | import { INPUT_KEY, TIME_TOGGLE, timeTick, timeToggle } from '../actions'; 4 | import { PERIOD } from '../constants'; 5 | 6 | function createTimer(msec) { 7 | return eventChannel(emit => { 8 | let timer = setInterval(() => { 9 | emit(1); 10 | }, msec); 11 | return () => { 12 | clearInterval(timer); 13 | }; 14 | }); 15 | } 16 | 17 | function* runTimer(msec) { 18 | const timer = createTimer(msec); 19 | try { 20 | while (true) { 21 | yield take(timer); 22 | yield put(timeTick()); 23 | } 24 | } finally { 25 | if (yield cancelled()) { 26 | timer.close(); 27 | } 28 | } 29 | } 30 | 31 | function* handleTimer() { 32 | while (true) { 33 | yield take(TIME_TOGGLE); 34 | const timer = yield fork(runTimer, PERIOD); 35 | yield take(TIME_TOGGLE); 36 | yield cancel(timer); 37 | } 38 | } 39 | 40 | function* controlByKeys() { 41 | while (true) { 42 | const { payload: key } = yield take(INPUT_KEY); 43 | switch (key) { 44 | case ' ': // Space key 45 | yield put(timeToggle()); 46 | break; 47 | } 48 | } 49 | } 50 | 51 | export default function* timerSaga() { 52 | yield fork(handleTimer); 53 | yield fork(controlByKeys); 54 | 55 | // Start time 56 | yield put(timeToggle()); 57 | } 58 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import logger from 'redux-logger'; 4 | import reducer from './reducers'; 5 | import sagas from './sagas'; 6 | 7 | export default function configureStore(initialState) { 8 | const sagaMiddleware = createSagaMiddleware(); 9 | const store = createStore( 10 | reducer, 11 | initialState, 12 | applyMiddleware( 13 | sagaMiddleware, logger() 14 | ) 15 | ); 16 | sagaMiddleware.run(sagas); 17 | return store; 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | declare type Triple = [number, number, number]; 4 | declare type Dimension = Triple; 5 | declare type Position = Triple; 6 | declare type Position1D = number; 7 | declare type PieceType = number; 8 | declare type Piece = { 9 | type: PieceType, 10 | position: Position, 11 | rotate?: any 12 | }; 13 | declare type Stage = number[]; 14 | declare type DirName = 'back' | 'right' | 'front' | 'left' | 'up' | 'down'; 15 | declare type Dir = -1 | 0 | 1; 16 | declare type Direction = [Dir, Dir, Dir]; 17 | declare type Range = [number, number]; 18 | 19 | export function to1D(dim: Dimension, pos: Position): Position1D { 20 | const [x, y, z] = pos; 21 | const [dx, dy, dz] = dim; 22 | return dx * dy * z + dx * y + x; 23 | } 24 | 25 | to1D.withDim = function createTo1DWithDim(dim: Dimension) { 26 | return function to1DWithDim(pos: Position): Position1D { 27 | return to1D(dim, pos); 28 | }; 29 | }; 30 | 31 | export function to3D(dim: Dimension, pos: Position1D): Position { 32 | const [dx, dy, dz] = dim; 33 | const z = Math.floor(pos / (dx * dy)); 34 | const y = Math.floor((pos % (dx * dy)) / dx); 35 | const x = (pos % (dx * dy)) % dx; 36 | return [x, y, z]; 37 | } 38 | 39 | to3D.withDim = function createTo3DWithDim(dim: Dimension) { 40 | return function to3DWithDim(pos: Position1D): Position { 41 | return to3D(dim, pos); 42 | }; 43 | }; 44 | 45 | export function empty(dim: Dimension): Stage { 46 | const [dx, dy, dz] = dim; 47 | const size = dx * dy * dz; 48 | return range(size).map(i => 0); 49 | } 50 | 51 | const PIECES = { 52 | '1': [[0, 0, 0]], 53 | '2': [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]], 54 | '3': [[0, 0, 0], [1, 0, 0], [2, 0, 0], [0, 1, 0]], 55 | '4': [[0, 0, 0], [1, 0, 0], [2, 0, 0], [2, 1, 0]], 56 | '5': [[0, 0, 0], [1, 0, 0], [2, 0, 0], [1, 1, 0]], 57 | }; 58 | 59 | export function numCubesByType(type: PieceType) { 60 | const cubes = PIECES[type.toString()]; 61 | if (typeof cubes === 'undefined') { 62 | throw new Error(`Unknown piece type: ${type}`); 63 | } 64 | 65 | return cubes.length; 66 | } 67 | 68 | export function rasterize(dim: Dimension, { type, position: base }: Piece): Stage { 69 | const cubes = PIECES[type.toString()]; 70 | if (typeof cubes === 'undefined') { 71 | throw new Error(`Unknown piece type: ${type}`); 72 | } 73 | 74 | const to = to1D.withDim(dim); 75 | const inStage = isInStage.withDim(dim); 76 | let stage = empty(dim); 77 | for (let cube of cubes) { 78 | const pos = add(base, cube); 79 | if (inStage(pos)) { 80 | stage[to(pos)] = 1; 81 | } 82 | } 83 | 84 | return stage; 85 | } 86 | 87 | export function zip(a1: T[], a2: S[]): [[T, S]] { 88 | return a1.map((e, i) => [e, a2[i]]); 89 | } 90 | 91 | export function merge(s1: Stage, s2: Stage): Stage { 92 | return zip(s1, s2).map(([a, b]) => 0 < a || 0 < b ? 1 : 0); 93 | } 94 | 95 | export function hasIntersection(s1: Stage, s2: Stage): bool { 96 | return 0 < zip(s1, s2).filter(([a, b]) => a === 1 && b === 1).length; 97 | } 98 | 99 | export function add(v1: number[], v2: number[]): number[] { 100 | return zip(v1, v2).map(([a, b]) => a + b); 101 | } 102 | 103 | export function isInStage(dim: Dimension, pos: Position): bool { 104 | const [dx, dy, dz] = dim; 105 | const [x, y, z] = pos; 106 | return 0 <= x && x < dx && 0 <= y && y < dy && 0 <= z && z < dz; 107 | } 108 | 109 | isInStage.withDim = function createIsInStage(dim: Dimension) { 110 | return function isInStageWithDim(pos: Position): bool { 111 | return isInStage(dim, pos); 112 | } 113 | } 114 | 115 | export function isValid(dim: Dimension, stage: Stage): bool { 116 | const [dx, dy, dz] = dim; 117 | return dx * dy * dz === stage.length; 118 | } 119 | 120 | export function dirToVec(dir: DirName): Direction { 121 | switch (dir) { 122 | case 'back': 123 | return [ 0, -1, 0]; 124 | case 'right': 125 | return [ 1, 0, 0]; 126 | case 'front': 127 | return [ 0, 1, 0]; 128 | case 'left': 129 | return [-1, 0, 0]; 130 | case 'up': 131 | return [ 0, 0, 1]; 132 | case 'down': 133 | return [ 0, 0, -1]; 134 | } 135 | throw new Error(`Invalid direction: ${dir}`); 136 | } 137 | 138 | export function move(piece: Piece, dir: DirName): Piece { 139 | return { ...piece, position: add(piece.position, dirToVec(dir)) }; 140 | } 141 | 142 | export function canMove(dim: Dimension, stage: Stage, piece: Piece, dir: DirName): bool { 143 | const num = numCubesByType(piece.type); 144 | const newPiece = move(piece, dir); 145 | const volume = rasterize(dim, newPiece); 146 | return numCubes(dim, volume) === num && !hasIntersection(stage, volume); 147 | } 148 | 149 | export function range(n: number): number[] { 150 | const list = []; 151 | for (let i = 0; i < n; i++) { 152 | list.push(i); 153 | } 154 | return list; 155 | } 156 | 157 | // volume: Rasterized piece 158 | export function eachCubes(dim: Dimension, volume: Stage): Position[] { 159 | if (dim[0] === 0 || dim[1] === 0 || dim[2] === 0) { 160 | return []; 161 | } 162 | const to = to3D.withDim(dim); 163 | return volume.map((d, i) => [i, d]) 164 | .filter(([i, d]) => d === 1).map(([i, d]) => to(i)); 165 | } 166 | 167 | export function numCubes(dim: Dimension, volume: Stage): number { 168 | return eachCubes(dim, volume).length; 169 | } 170 | 171 | export function rand(max: number): number { 172 | return Math.floor(max * Math.random()); 173 | } 174 | 175 | export function sliceX(dim: Dimension, volume: Stage, [begin, end]: Range): Stage { 176 | const to = to3D.withDim(dim); 177 | const newVol = []; 178 | volume.forEach((d, i) => { 179 | const [x, y, z] = to(i); 180 | if (begin <= x && x < end) { 181 | newVol.push(d); 182 | } 183 | }); 184 | return newVol; 185 | } 186 | 187 | export function sliceY(dim: Dimension, volume: Stage, [begin, end]: Range): Stage { 188 | const to = to3D.withDim(dim); 189 | const newVol = []; 190 | volume.forEach((d, i) => { 191 | const [x, y, z] = to(i); 192 | if (begin <= y && y < end) { 193 | newVol.push(d); 194 | } 195 | }); 196 | return newVol; 197 | } 198 | 199 | export function sliceZ(dim: Dimension, volume: Stage, [begin, end]: Range): Stage { 200 | const to = to3D.withDim(dim); 201 | const newVol = []; 202 | volume.forEach((d, i) => { 203 | const [x, y, z] = to(i); 204 | if (begin <= z && z < end) { 205 | newVol.push(d); 206 | } 207 | }); 208 | return newVol; 209 | } 210 | 211 | export function shrink(dim: Dimension, volume: Stage): Dimension { 212 | const count = eachCubes(dim, volume).length; 213 | let [dx, dy, dz] = dim; 214 | 215 | // X 216 | const rx = [0, dx]; 217 | while (rx[0] < rx[1]) { 218 | const rx0 = rx[0] + 1; 219 | if (numCubes([rx[1] - rx0, dy, dz], sliceX(dim, volume, [rx0, rx[1]])) < count) { 220 | break; 221 | } 222 | rx[0] = rx0; 223 | } 224 | while (rx[0] < rx[1]) { 225 | const rx1 = rx[1] - 1; 226 | if (numCubes([rx1 - rx[0], dy, dz], sliceX(dim, volume, [rx[0], rx1])) < count) { 227 | break; 228 | } 229 | rx[1] = rx1; 230 | } 231 | 232 | // Y 233 | const ry = [0, dy]; 234 | while (ry[0] < ry[1]) { 235 | const ry0 = ry[0] + 1; 236 | if (numCubes([rx[1] - rx[0], ry[1] - ry0, dz], sliceY(dim, volume, [ry0, ry[1]])) < count) { 237 | break; 238 | } 239 | ry[0] = ry0; 240 | } 241 | while (ry[0] < ry[1]) { 242 | const ry1 = ry[1] - 1; 243 | if (numCubes([rx[1] - rx[0], ry1 - ry[0], dz], sliceY(dim, volume, [ry[0], ry1])) < count) { 244 | break; 245 | } 246 | ry[1] = ry1; 247 | } 248 | 249 | // Z 250 | const rz = [0, dz]; 251 | while (rz[0] < rz[1]) { 252 | const rz0 = rz[0] + 1; 253 | if (numCubes([rx[1] - rx[0], ry[1] - ry[0], rz[1] - rz0], sliceZ(dim, volume, [rz0, rz[1]])) < count) { 254 | break; 255 | } 256 | rz[0] = rz0; 257 | } 258 | while (rz[0] < rz[1]) { 259 | const rz1 = rz[1] - 1; 260 | if (numCubes([rx[1] - rx[0], ry[1] - ry[0], rz1 - rz[0]], sliceZ(dim, volume, [rz[0], rz1])) < count) { 261 | break; 262 | } 263 | rz[1] = rz1; 264 | } 265 | 266 | return [rx[1] - rx[0], ry[1] - ry[0], rz[1] - rz[0]]; 267 | } 268 | -------------------------------------------------------------------------------- /test/spec/reducers.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | import reducers, { initial } from '../../src/reducers'; 3 | import { score, timeTick } from '../../src/actions'; 4 | 5 | describe('reducers', () => { 6 | describe('app', () => { 7 | it('increases score', () => { 8 | let state = reducers(initial.app, score(100)); 9 | assert(state.app.score === 100); 10 | 11 | state = reducers(state, score(50)) 12 | assert(state.app.score === 150); 13 | 14 | state = reducers(state, score(0)) 15 | assert(state.app.score === 150); 16 | }); 17 | 18 | it('increases time', () => { 19 | let state = reducers(initial.app, timeTick()); 20 | assert(state.app.time === 1); 21 | 22 | state = reducers(state, timeTick()) 23 | assert(state.app.time === 2); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/spec/utils.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | import { zip, merge, rasterize, to1D, to3D, isValid, isGrounded, range, eachCubes, isExist, empty, sliceX, sliceY, sliceZ, shrink } from '../../src/utils'; 3 | 4 | describe('utils', () => { 5 | describe('zip', () => { 6 | it('should make pairs', () => { 7 | assert.deepStrictEqual(zip([0, 1, 2], ['a', 'b', 'c']), [[0, 'a'], [1, 'b'], [2, 'c']]); 8 | }); 9 | }); 10 | 11 | describe('merge', () => { 12 | it('should merge two array', () => { 13 | assert.deepStrictEqual(merge([0, 0, 1, 1], [1, 0, 0, 0]), [1, 0, 1, 1]); 14 | }); 15 | }); 16 | 17 | describe('rasterize', () => { 18 | it('should fill with piece', () => { 19 | assert.deepStrictEqual(rasterize([1, 1, 1], { type: 1, position: [0, 0, 0] }), [1]); 20 | assert.deepStrictEqual(rasterize([1, 1, 1], { type: 1, position: [1, 0, 0] }), [0]); 21 | assert.deepStrictEqual(rasterize([1, 1, 1], { type: 1, position: [0, 1, 0] }), [0]); 22 | assert.deepStrictEqual(rasterize([1, 1, 1], { type: 1, position: [0, 0, 1] }), [0]); 23 | 24 | assert.deepStrictEqual(rasterize([4, 2, 1], { type: 2, position: [0, 0, 0] }), [1, 1, 1, 1, 25 | 0, 0, 0, 0]); 26 | assert.deepStrictEqual(rasterize([4, 2, 1], { type: 2, position: [1, 0, 0] }), [0, 1, 1, 1, 27 | 0, 0, 0, 0]); 28 | 29 | // assert.deepStrictEqual(rasterize([2, 2, 2], { type: 1, position: [1, 1, 0] }), [0, 0, 0, 1, 0, 0, 0, 0]); 30 | // assert.deepStrictEqual(rasterize([3, 3, 3], { type: 1, position: [1, 1, 0] }), [0, 0, 0, 1, 0, 0, 0, 0]); 31 | // assert.deepStrictEqual(rasterize([4, 4, 4], { type: 1, position: [1, 1, 0] }), [0, 0, 0, 1, 0, 0, 0, 0]); 32 | }); 33 | }); 34 | 35 | describe('to1D', () => { 36 | it('should convert 3D to 1D', () => { 37 | // 2x2x2 38 | let dim = [2, 2, 2]; 39 | let pos = [0, 0, 0]; 40 | assert(to1D(dim, pos) === 0); 41 | 42 | pos = [1, 0, 1]; 43 | assert(to1D(dim, pos) === 5); 44 | 45 | pos = [1, 1, 1]; 46 | assert(to1D(dim, pos) === 7); 47 | 48 | // 2x3x4 49 | dim = [2, 3, 4]; 50 | pos = [0, 0, 0]; 51 | assert(to1D(dim, pos) === 0); 52 | 53 | pos = [1, 2, 1]; 54 | assert(to1D(dim, pos) === 11); 55 | 56 | pos = [0, 1, 2]; 57 | assert(to1D(dim, pos) === 14); 58 | 59 | pos = [1, 2, 3]; 60 | assert(to1D(dim, pos) === 23); 61 | }); 62 | }); 63 | 64 | describe('to3D', () => { 65 | it('should convert 1D to 3D', () => { 66 | // 2x2x2 67 | let dim = [2, 2, 2]; 68 | assert.deepStrictEqual(to3D(dim, 0), [0, 0, 0]); 69 | assert.deepStrictEqual(to3D(dim, 1), [1, 0, 0]); 70 | assert.deepStrictEqual(to3D(dim, 2), [0, 1, 0]); 71 | assert.deepStrictEqual(to3D(dim, 3), [1, 1, 0]); 72 | assert.deepStrictEqual(to3D(dim, 4), [0, 0, 1]); 73 | assert.deepStrictEqual(to3D(dim, 5), [1, 0, 1]); 74 | assert.deepStrictEqual(to3D(dim, 6), [0, 1, 1]); 75 | assert.deepStrictEqual(to3D(dim, 7), [1, 1, 1]); 76 | }); 77 | }); 78 | 79 | describe('isValid', () => { 80 | it('should validate stage and its dimension', () => { 81 | assert(isValid([2, 2, 2], [0, 0, 0, 0, 0, 0, 0, 0])); 82 | assert(isValid([3, 2, 1], [0, 0, 0, 0, 0, 0])); 83 | assert(isValid([3, 3, 3], [0, 0, 0, 0, 0, 0, 0, 0, 0, 84 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 85 | 0, 0, 0, 0, 0, 0, 0, 0, 0])); 86 | }); 87 | }); 88 | 89 | describe('range', () => { 90 | it('should generate a list of 0 to the given number', () => { 91 | assert.deepStrictEqual(range(0), []); 92 | assert.deepStrictEqual(range(1), [0]); 93 | assert.deepStrictEqual(range(5), [0, 1, 2, 3, 4]); 94 | assert.deepStrictEqual(range(10), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 95 | }); 96 | }); 97 | 98 | describe('eachCubes', () => { 99 | it('should iterate cubes from rasterized piece', () => { 100 | assert.deepStrictEqual(eachCubes([0, 1, 1], []), []); 101 | assert.deepStrictEqual(eachCubes([0, 1, 1], []), []); 102 | assert.deepStrictEqual(eachCubes([1, 1, 1], [0]), []); 103 | assert.deepStrictEqual(eachCubes([1, 1, 1], [1]), [[0, 0, 0]]); 104 | assert.deepStrictEqual(eachCubes([2, 2, 2], [0, 1, 0, 0, 1, 1, 0, 1]), [[1, 0, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1]]); 105 | assert.deepStrictEqual(eachCubes([3, 3, 3], [0, 0, 0, 0, 0, 0, 0, 0, 0, 106 | 0, 0, 0, 0, 0, 1, 0, 0, 0, 107 | 0, 0, 1, 0, 0, 1, 0, 0, 1]), [[2, 1, 1], [2, 0, 2], [2, 1, 2], [2, 2, 2]]); 108 | }); 109 | }); 110 | 111 | describe('empty', () => { 112 | it('should generate a zero-padding list based on given dimension', () => { 113 | assert.deepStrictEqual(empty([0, 0, 0]), []); 114 | assert.deepStrictEqual(empty([1, 1, 1]), [0]); 115 | assert.deepStrictEqual(empty([2, 2, 2]), [0, 0, 0, 0, 0, 0, 0, 0]); 116 | assert.deepStrictEqual(empty([1, 2, 3]), [0, 0, 0, 0, 0, 0]); 117 | assert.deepStrictEqual(empty([3, 2, 1]), [0, 0, 0, 0, 0, 0]); 118 | assert.deepStrictEqual(empty([3, 3, 3]), [0, 0, 0, 0, 0, 0, 0, 0, 0, 119 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 120 | 0, 0, 0, 0, 0, 0, 0, 0, 0]); 121 | }); 122 | }); 123 | 124 | describe('sliceX', () => { 125 | it('should cut out a new volume from given volume based on x-axis range', () => { 126 | assert.deepStrictEqual(sliceX([2, 2, 1], [1, 0, 1, 0], [0, 1]), [1, 1]); 127 | assert.deepStrictEqual(sliceX([2, 2, 1], [1, 0, 1, 0], [1, 2]), [0, 0]); 128 | assert.deepStrictEqual(sliceX([2, 2, 2], [1, 0, 1, 0, 1, 0, 1, 0], [0, 1]), [1, 1, 1, 1]); 129 | assert.deepStrictEqual(sliceX([2, 2, 2], [1, 0, 1, 0, 1, 0, 1, 0], [1, 2]), [0, 0, 0, 0]); 130 | assert.deepStrictEqual(sliceX([2, 2, 2], [1, 0, 1, 0, 1, 0, 1, 0], [0, 2]), [1, 0, 1, 0, 1, 0, 1, 0]); 131 | assert.deepStrictEqual(sliceX([4, 2, 1], [0, 1, 1, 0, 0, 1, 1, 0], [1, 3]), [1, 1, 1, 1]); 132 | assert.deepStrictEqual(sliceX([4, 2, 1], [0, 1, 1, 0, 0, 1, 1, 0], [2, 4]), [1, 0, 1, 0]); 133 | assert.deepStrictEqual(sliceX([3, 3, 3], [0, 0, 1, 0, 0, 0, 0, 0, 1, 134 | 0, 0, 0, 0, 0, 1, 0, 0, 0, 135 | 0, 0, 1, 0, 0, 0, 0, 0, 1], [2, 3]), [1, 0, 1, 0, 1, 0, 1, 0, 1]); 136 | }); 137 | }); 138 | 139 | describe('sliceY', () => { 140 | it('should cut out a new volume from given volume based on y-axis range', () => { 141 | assert.deepStrictEqual(sliceY([2, 2, 1], [1, 1, 0, 0], [0, 1]), [1, 1]); 142 | assert.deepStrictEqual(sliceY([2, 2, 1], [1, 1, 0, 0], [1, 2]), [0, 0]); 143 | assert.deepStrictEqual(sliceY([2, 2, 2], [1, 1, 0, 0, 1, 1, 0, 0], [0, 1]), [1, 1, 1, 1]); 144 | assert.deepStrictEqual(sliceY([2, 2, 2], [1, 1, 0, 0, 1, 1, 0, 0], [1, 2]), [0, 0, 0, 0]); 145 | assert.deepStrictEqual(sliceY([2, 2, 2], [1, 1, 0, 0, 1, 1, 0, 0], [0, 2]), [1, 1, 0, 0, 1, 1, 0, 0]); 146 | assert.deepStrictEqual(sliceY([2, 4, 1], [0, 0, 1, 1, 1, 1, 0, 0], [1, 3]), [1, 1, 1, 1]); 147 | assert.deepStrictEqual(sliceY([2, 4, 1], [0, 0, 1, 1, 1, 1, 0, 0], [2, 4]), [1, 1, 0, 0]); 148 | assert.deepStrictEqual(sliceY([3, 3, 3], [0, 0, 0, 0, 0, 0, 1, 1, 1, 149 | 0, 0, 0, 0, 1, 0, 1, 0, 1, 150 | 0, 0, 0, 0, 0, 0, 1, 1, 1], [2, 3]), [1, 1, 1, 1, 0, 1, 1, 1, 1]); 151 | }); 152 | }); 153 | 154 | describe('sliceZ', () => { 155 | it('should cut out a new volume from given volume based on z-axis range', () => { 156 | assert.deepStrictEqual(sliceZ([2, 2, 2], [1, 1, 1, 1, 0, 0, 0, 0], [0, 1]), [1, 1, 1, 1]); 157 | assert.deepStrictEqual(sliceZ([2, 2, 2], [1, 1, 1, 1, 0, 0, 0, 0], [1, 2]), [0, 0, 0, 0]); 158 | assert.deepStrictEqual(sliceZ([2, 2, 2], [1, 1, 1, 1, 0, 0, 0, 0], [0, 2]), [1, 1, 1, 1, 0, 0, 0, 0]); 159 | assert.deepStrictEqual(sliceZ([1, 2, 4], [0, 0, 1, 1, 1, 1, 0, 0], [1, 3]), [1, 1, 1, 1]); 160 | assert.deepStrictEqual(sliceZ([1, 2, 4], [0, 0, 1, 1, 1, 1, 0, 0], [2, 4]), [1, 1, 0, 0]); 161 | assert.deepStrictEqual(sliceZ([3, 3, 3], [0, 0, 0, 0, 0, 0, 0, 0, 0, 162 | 0, 0, 0, 0, 1, 0, 0, 0, 0, 163 | 1, 0, 0, 0, 1, 0, 0, 0, 1], [2, 3]), [1, 0, 0, 0, 1, 0, 0, 0, 1]); 164 | }); 165 | }); 166 | 167 | describe('shrink', () => { 168 | it('should return a shrinked volume', () => { 169 | assert.deepStrictEqual(shrink([1, 1, 1], [0]), [0, 0, 0]); 170 | assert.deepStrictEqual(shrink([1, 1, 1], [1]), [1, 1, 1]); 171 | 172 | assert.deepStrictEqual(shrink([4, 2, 1], [0, 0, 0, 0, 0, 0, 0, 0]), [0, 0, 0]); 173 | assert.deepStrictEqual(shrink([4, 2, 1], [1, 0, 0, 0, 0, 0, 0, 0]), [1, 1, 1]); 174 | assert.deepStrictEqual(shrink([4, 2, 1], [1, 1, 0, 0, 0, 0, 0, 0]), [2, 1, 1]); 175 | assert.deepStrictEqual(shrink([4, 2, 1], [1, 1, 1, 0, 1, 0, 0, 0]), [3, 2, 1]); 176 | assert.deepStrictEqual(shrink([3, 3, 2], [0, 0, 0, 0, 0, 0, 0, 0, 0, 177 | 1, 1, 1, 0, 0, 1, 0, 0, 0]), [3, 2, 1]); 178 | assert.deepStrictEqual(shrink([3, 3, 3], [0, 0, 0, 0, 0, 1, 0, 1, 1, 179 | 0, 0, 0, 0, 0, 0, 0, 0, 1]), [2, 2, 2]); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | module: { 6 | loaders: [{ 7 | test: /\.js$/, 8 | loader: 'babel', 9 | exclude: /node_modules/ 10 | }] 11 | }, 12 | output: { 13 | path: __dirname + '/build', 14 | filename: 'bundle.js', 15 | publicPath: '/in-memory' 16 | }, 17 | devtool: 'inline-source-map' 18 | }; 19 | --------------------------------------------------------------------------------