├── .gitignore ├── assets ├── sounds.mp3 ├── audio │ ├── drop.wav │ ├── hold.wav │ ├── lock.wav │ ├── move.wav │ ├── level-up.wav │ ├── rotate.wav │ ├── clear-line.wav │ └── game-over.wav ├── fonts │ ├── icons.woff │ └── icons.woff2 ├── images │ └── pattern.png ├── stylesheets │ ├── _social.scss │ ├── _animations.scss │ ├── _nav.scss │ ├── _modal.scss │ ├── _buttons.scss │ ├── styles.scss │ ├── _type.scss │ └── _game.scss └── sounds.json ├── src ├── log.js ├── sound.js ├── Message.js ├── views │ ├── PlayfieldView.js │ ├── BlockView.js │ ├── RootView.js │ ├── GameOverView.js │ ├── TetrominoView.js │ ├── SocialView.js │ ├── TetrionView.js │ ├── GameView.js │ └── HelpView.js ├── Block.js ├── Progress.js ├── Bag.js ├── Vector.js ├── Transform.js ├── Playfield.js ├── index.js ├── Reward.js ├── Tetromino.js ├── srs.js ├── Game.js └── Tetrion.js ├── .postcssrc ├── babel.config.js ├── .editorconfig ├── .travis.yml ├── Makefile ├── index.html ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /assets/sounds.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/sounds.mp3 -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | import nanologger from 'nanologger' 2 | 3 | export default nanologger('game') 4 | -------------------------------------------------------------------------------- /assets/audio/drop.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/drop.wav -------------------------------------------------------------------------------- /assets/audio/hold.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/hold.wav -------------------------------------------------------------------------------- /assets/audio/lock.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/lock.wav -------------------------------------------------------------------------------- /assets/audio/move.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/move.wav -------------------------------------------------------------------------------- /.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "modules": true, 3 | "plugins": { 4 | "autoprefixer": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /assets/audio/level-up.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/level-up.wav -------------------------------------------------------------------------------- /assets/audio/rotate.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/rotate.wav -------------------------------------------------------------------------------- /assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/fonts/icons.woff -------------------------------------------------------------------------------- /assets/fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/fonts/icons.woff2 -------------------------------------------------------------------------------- /assets/images/pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/images/pattern.png -------------------------------------------------------------------------------- /assets/audio/clear-line.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/clear-line.wav -------------------------------------------------------------------------------- /assets/audio/game-over.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/tetris/HEAD/assets/audio/game-over.wav -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-react'] 3 | } 4 | -------------------------------------------------------------------------------- /assets/stylesheets/_social.scss: -------------------------------------------------------------------------------- 1 | .social { 2 | font-size: 2rem; 3 | margin-top: 4rem; 4 | 5 | a { 6 | padding: 0 0.25rem; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | -------------------------------------------------------------------------------- /src/sound.js: -------------------------------------------------------------------------------- 1 | import { Howl } from 'howler' 2 | 3 | import sounds from '../assets/sounds.mp3' 4 | import { sprite } from '../assets/sounds.json' 5 | 6 | const sound = new Howl({ src: [sounds], sprite }) 7 | 8 | export function play (id) { 9 | if (id) { 10 | sound.play(id) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | env: 5 | global: 6 | - PATH=$HOME/.local/bin:$PATH 7 | before_install: pip install --user awscli 8 | before_deploy: make build 9 | deploy: 10 | on: 11 | branch: master 12 | provider: script 13 | script: make deploy 14 | skip_cleanup: true 15 | -------------------------------------------------------------------------------- /src/Message.js: -------------------------------------------------------------------------------- 1 | let nextId = 1 2 | 3 | /** 4 | * A message represents a message to show to the player. 5 | */ 6 | export default class Message { 7 | constructor (text) { 8 | this.text = text 9 | this.id = nextId++ 10 | } 11 | 12 | toString () { return `Message (id: ${this.id}, text: ${this.text})` } 13 | } 14 | -------------------------------------------------------------------------------- /assets/stylesheets/_animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes puff { 2 | 0% { transform: scale(0, 0); opacity: 0; } 3 | 50% { opacity: 1; } 4 | 100% { transform: scale(1.2, 1.2); opacity: 0; } 5 | } 6 | 7 | @keyframes zoom-in { 8 | 0% { transform: scale(0, 0); opacity: 0; } 9 | 50% { transform: scale(1, 1); } 10 | 100% { opacity: 1; } 11 | } 12 | -------------------------------------------------------------------------------- /src/views/PlayfieldView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import BlockView from './BlockView' 4 | import styles from '../../assets/stylesheets/styles.scss' 5 | 6 | export default ({ playfield: { blocks } }) => 7 | 10 | -------------------------------------------------------------------------------- /assets/stylesheets/_nav.scss: -------------------------------------------------------------------------------- 1 | nav { 2 | a { 3 | border: none; 4 | padding: 0 0.25rem; 5 | text-decoration: none; 6 | transition: text-shadow 0.15s ease-in-out; 7 | 8 | &:hover { 9 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); 10 | } 11 | 12 | &:active { 13 | text-shadow: none; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /assets/stylesheets/_modal.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | overflow-y: auto; 8 | outline: 0; 9 | background-color: rgba(0, 0, 0, 0.9); 10 | animation: zoom-in 0.2s ease-out both; 11 | 12 | footer { 13 | text-align: center; 14 | margin-top: 2rem; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/views/BlockView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from '../../assets/stylesheets/styles.scss' 4 | 5 | const BLOCK_SIZE = 23 6 | 7 | export default ({ block: { position, color } }) => { 8 | const style = { bottom: position.y * BLOCK_SIZE, left: position.x * BLOCK_SIZE } 9 | return
  • 10 | } 11 | -------------------------------------------------------------------------------- /src/Block.js: -------------------------------------------------------------------------------- 1 | let nextId = 1 2 | 3 | /** 4 | * A block represents a position and color. 5 | */ 6 | export default class Block { 7 | constructor (position, color) { 8 | this.id = nextId++ 9 | this.position = position 10 | this.color = color 11 | } 12 | 13 | toString () { return `Block (id: ${this.id}, position: ${this.position}, color: ${this.color})` } 14 | } 15 | -------------------------------------------------------------------------------- /src/views/RootView.js: -------------------------------------------------------------------------------- 1 | import 'normalize.css' 2 | import React from 'react' 3 | 4 | import GameOverView from './GameOverView' 5 | import GameView from './GameView' 6 | import HelpView from './HelpView' 7 | 8 | export default ({ bus, state: { game } }) => 9 | 10 | 11 | {game.paused ? : null} 12 | {game.over ? : null} 13 | 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean deploy lint start test 2 | 3 | start: 4 | @npx parcel -p 4000 index.html 5 | 6 | build: 7 | @npx parcel build index.html --public-url . 8 | 9 | sounds: 10 | @npx audiosprite -e mp3 -f howler -o assets/sounds assets/audio/*.wav 11 | 12 | deploy: build 13 | @aws s3 sync ./dist/ s3://tetris.joshbassett.info/ --acl public-read --delete --cache-control 'max-age=300' 14 | 15 | test: lint 16 | 17 | lint: 18 | @npx standard "src/**/*.js" 19 | 20 | clean: 21 | @rm -rf dist node_modules 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tetris 6 | 7 | 13 | 14 | 15 |
    16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/views/GameOverView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | 4 | import SocialView from './SocialView' 5 | import styles from '../../assets/stylesheets/styles.scss' 6 | 7 | export default ({ bus }) => 8 |
    9 |
    10 |

    Game Over

    11 |
    12 | 13 | 14 |
    15 |
    16 |
    17 | -------------------------------------------------------------------------------- /src/views/TetrominoView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | 4 | import BlockView from './BlockView' 5 | import styles from '../../assets/stylesheets/styles.scss' 6 | 7 | export default ({ ghost, tetromino: { shape, blocks } }) => { 8 | const className = classnames( 9 | styles.tetromino, 10 | styles[`shape-${shape.toLowerCase()}`], 11 | { [styles.ghostPiece]: ghost } 12 | ) 13 | 14 | return ( 15 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/Progress.js: -------------------------------------------------------------------------------- 1 | import { copy } from 'fkit' 2 | 3 | /** 4 | * Represents the player progress with the level, number of lines cleared, and 5 | * score. 6 | */ 7 | export default class Progress { 8 | constructor () { 9 | this.lines = 0 10 | this.score = 0 11 | } 12 | 13 | /** 14 | * Returns the level (between 1 and 20). 15 | */ 16 | get level () { 17 | return Math.min(Math.floor(this.lines / 10) + 1, 20) 18 | } 19 | 20 | add (reward) { 21 | const lines = this.lines + reward.lines 22 | const score = this.score + reward.points 23 | return copy(this, { lines, score }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /assets/sounds.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | "assets/sounds.mp3" 4 | ], 5 | "sprite": { 6 | "clear-line": [ 7 | 0, 8 | 361.3605442176871 9 | ], 10 | "drop": [ 11 | 2000, 12 | 272.97052154195 13 | ], 14 | "game-over": [ 15 | 4000, 16 | 862.3809523809527 17 | ], 18 | "hold": [ 19 | 6000, 20 | 164.48979591836732 21 | ], 22 | "level-up": [ 23 | 8000, 24 | 868.5034013605435 25 | ], 26 | "lock": [ 27 | 10000, 28 | 61.99546485260754 29 | ], 30 | "move": [ 31 | 12000, 32 | 17.052154195011937 33 | ], 34 | "rotate": [ 35 | 14000, 36 | 48.43537414966015 37 | ] 38 | } 39 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tetris 2 | 3 | [![Build Status](https://travis-ci.org/nullobject/tetris.svg?branch=master)](https://travis-ci.org/nullobject/tetris) 4 | 5 | This is an implementation of the popular game Tetris. 6 | 7 | It is written as a reactive JavaScript application using [FKit](https://github.com/nullobject/fkit) and [Bulb](https://github.com/nullobject/bulb). 8 | 9 | ## Development 10 | 11 | To start a development server: 12 | 13 | > make start 14 | 15 | To run the tests: 16 | 17 | > make test 18 | 19 | To deploy the app: 20 | 21 | > make deploy 22 | 23 | ## Licence 24 | 25 | This implementation of Tetris is licensed under the MIT licence. See the 26 | [LICENCE](https://github.com/nullobject/tetris/blob/master/LICENCE.md) file for 27 | more details. 28 | -------------------------------------------------------------------------------- /src/views/SocialView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from '../../assets/stylesheets/styles.scss' 4 | 5 | const TWITTER_URL = 'https://twitter.com/intent/tweet?text=Wanna%20play%20some%20Tetris%3F&url=https%3A%2F%2Ftetris.joshbassett.info' 6 | const FACEBOOK_URL = 'http://www.facebook.com/sharer.php?u=https%3A%2F%2Ftetris.joshbassett.info' 7 | const GITHUB_URL = 'https://github.com/nullobject/tetris' 8 | 9 | export default () => 10 | 15 | -------------------------------------------------------------------------------- /assets/stylesheets/_buttons.scss: -------------------------------------------------------------------------------- 1 | button { 2 | display: inline-block; 3 | cursor: pointer; 4 | text-align: center; 5 | vertical-align: middle; 6 | user-select: none; 7 | padding: 0.5rem 0.75rem 0.375rem 0.75rem; 8 | font-family: inherit; 9 | font-size: 1rem; 10 | font-weight: 400; 11 | letter-spacing: inherit; 12 | line-height: inherit; 13 | border-radius: 0.25rem; 14 | text-transform: uppercase; 15 | background-color: transparent; 16 | border: 1px solid white; 17 | color: white; 18 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; 19 | 20 | &:focus { 21 | outline: 0; 22 | } 23 | 24 | &:hover { 25 | background-color: white; 26 | border-color: white; 27 | color: black; 28 | } 29 | 30 | &:active { 31 | background-color: inherit; 32 | border-color: inherit; 33 | color: inherit; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Bag.js: -------------------------------------------------------------------------------- 1 | import { copy, empty, head, keys, shuffle, tail } from 'fkit' 2 | 3 | import SRS from './srs' 4 | 5 | /** 6 | * Returns an array of all the shapes randomly shuffled. 7 | */ 8 | function refill () { 9 | return shuffle(keys(SRS)) 10 | } 11 | 12 | export default class Bag { 13 | constructor () { 14 | this.shapes = refill() 15 | } 16 | 17 | /** 18 | * Returns the next shape in the bag. 19 | */ 20 | get next () { 21 | return head(this.shapes) 22 | } 23 | 24 | /** 25 | * Removes the next shape from the bag. If the bag is empty, then it is 26 | * refilled. 27 | * 28 | * @returns An object containing the shape and the new bag state. 29 | */ 30 | shift () { 31 | const shape = head(this.shapes) 32 | let shapes = tail(this.shapes) 33 | 34 | if (empty(shapes)) { 35 | shapes = refill() 36 | } 37 | 38 | return { bag: copy(this, { shapes }), shape } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/views/TetrionView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Transition, TransitionGroup } from 'react-transition-group' 3 | 4 | import PlayfieldView from './PlayfieldView' 5 | import TetrominoView from './TetrominoView' 6 | import styles from '../../assets/stylesheets/styles.scss' 7 | 8 | const Message = ({ text, ...props }) => ( 9 | 10 |
    {text}
    11 |
    12 | ) 13 | 14 | export default ({ message, tetrion: { playfield, fallingPiece, ghostPiece } }) => 15 |
    16 | 17 | {fallingPiece ? : null} 18 | {ghostPiece ? : null} 19 | 20 | 21 | {message ? : null} 22 | 23 |
    24 | -------------------------------------------------------------------------------- /assets/stylesheets/styles.scss: -------------------------------------------------------------------------------- 1 | $block_size: 23px; 2 | $block_inset: 3px; 3 | 4 | $bg_color_1: fade-out(#ff84cb, 0.08); 5 | $bg_color_2: fade-out(#ffc350, 0.02); 6 | 7 | $playfield_color: rgba(0, 0, 0, 0.8); 8 | 9 | @import "./animations"; 10 | @import "./buttons"; 11 | @import "./game"; 12 | @import "./modal"; 13 | @import "./nav"; 14 | @import "./social"; 15 | @import "./type"; 16 | 17 | * { 18 | touch-callout: none; 19 | user-select: none; 20 | } 21 | 22 | body { 23 | color: white; 24 | background-image: linear-gradient(to bottom, $bg_color_1, $bg_color_2), url("../images/pattern.png"); 25 | align-items: center; 26 | display: flex; 27 | justify-content: center; 28 | } 29 | 30 | html, body { 31 | height: 100%; 32 | } 33 | 34 | .container { 35 | margin-right: auto; 36 | margin-left: auto; 37 | padding: 1rem; 38 | width: 600px; 39 | 40 | h1, h2, h3, h4, h5, h6 { 41 | text-align: center; 42 | } 43 | } 44 | 45 | .row { 46 | display: flex; 47 | } 48 | 49 | .align-self-center { 50 | align-self: center !important; 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tetris", 3 | "version": "1.0.0", 4 | "description": "", 5 | "author": "Josh Bassett (http://joshbassett.info)", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "start": "make start", 10 | "test": "make test" 11 | }, 12 | "standard": { 13 | "env": "jest", 14 | "ignore": [ 15 | "srs.js" 16 | ], 17 | "parser": "babel-eslint" 18 | }, 19 | "devDependencies": { 20 | "audiosprite": "^0.7.1", 21 | "@babel/core": "^7.4.3", 22 | "@babel/preset-env": "^7.4.3", 23 | "@babel/preset-react": "^7.0.0", 24 | "babel-eslint": "^10.0.1", 25 | "parcel-bundler": "^1.12.3", 26 | "standard": "^12.0.1" 27 | }, 28 | "dependencies": { 29 | "autoprefixer": "^9.5.1", 30 | "bulb": "^6.1.0", 31 | "bulb-input": "^6.1.0", 32 | "classnames": "^2.2.6", 33 | "fkit": "^3.2.0", 34 | "howler": "^2.1.2", 35 | "nanologger": "^1.3.1", 36 | "node-sass": "^4.11.0", 37 | "normalize.css": "^8.0.1", 38 | "postcss-modules": "^1.4.1", 39 | "react": "^16.8.6", 40 | "react-dom": "^16.8.6", 41 | "react-transition-group": "4.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Vector.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A `Vector` represents a position. 3 | */ 4 | export default class Vector { 5 | // Pre-canned vectors. 6 | static get zero () { return new Vector(0, 0) } 7 | 8 | constructor (...args) { 9 | if (args.length === 1 && Array.isArray(args[0])) { 10 | this.x = args[0][0] 11 | this.y = args[0][1] 12 | } else { 13 | this.x = args[0] 14 | this.y = args[1] 15 | } 16 | } 17 | 18 | /** 19 | * Returns true if this is a zero vector. 20 | */ 21 | get isZero () { return this.isEqual(Vector.zero) } 22 | 23 | /** 24 | * Adds the given vector. 25 | * 26 | * @param other A vector. 27 | * @returns A new vector. 28 | */ 29 | add (other) { 30 | if (Array.isArray(other)) { 31 | other = new Vector(other) 32 | } 33 | 34 | return new Vector(this.x + other.x, this.y + other.y) 35 | } 36 | 37 | /** 38 | * Subtracts the given vector. 39 | * 40 | * @param other A vector. 41 | * @returns A new vector. 42 | */ 43 | sub (other) { 44 | if (Array.isArray(other)) { 45 | other = new Vector(other) 46 | } 47 | 48 | return new Vector(this.x - other.x, this.y - other.y) 49 | } 50 | 51 | isEqual (other) { 52 | return this.x === other.x && this.y === other.y 53 | } 54 | 55 | toString () { return `Vector (x: ${this.x}, y: ${this.y})` } 56 | } 57 | -------------------------------------------------------------------------------- /src/views/GameView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import TetrionView from './TetrionView' 4 | import TetrominoView from './TetrominoView' 5 | import styles from '../../assets/stylesheets/styles.scss' 6 | 7 | export default ({ bus, game }) => { 8 | let message 9 | 10 | if (game.reward && game.reward.message) { 11 | message = game.reward.message 12 | } 13 | 14 | return ( 15 |
    16 | 31 | 32 | 33 | 34 | 45 |
    46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/Transform.js: -------------------------------------------------------------------------------- 1 | import Vector from './Vector' 2 | 3 | /** 4 | * A `Transform` represents a position and a rotation. 5 | */ 6 | export default class Transform { 7 | // Pre-canned transforms. 8 | static get zero () { return new Transform([0, 0], 0) } 9 | static get up () { return new Transform([0, 1], 0) } 10 | static get down () { return new Transform([0, -1], 0) } 11 | static get left () { return new Transform([-1, 0], 0) } 12 | static get right () { return new Transform([1, 0], 0) } 13 | static get rotateLeft () { return new Transform([0, 0], -1) } 14 | static get rotateRight () { return new Transform([0, 0], 1) } 15 | 16 | constructor (vector = Vector.zero, rotation = 0) { 17 | if (Array.isArray(vector)) { 18 | this.vector = new Vector(vector) 19 | } else { 20 | this.vector = vector 21 | } 22 | 23 | this.rotation = Math.abs((rotation + 4) % 4) 24 | } 25 | 26 | /** 27 | * Returns true if the transform represents a rotation, false otherwise. 28 | */ 29 | get isRotation () { return this.rotation !== 0 } 30 | 31 | /** 32 | * Returns true if the transform represents a translation, false otherwise. 33 | */ 34 | get isTranslation () { return this.vector.x !== 0 || this.vector.y !== 0 } 35 | 36 | /** 37 | * Returns true if this transform is a zero transform. 38 | */ 39 | get isZero () { return this.vector.isZero && this.rotation === 0 } 40 | 41 | /** 42 | * Adds the given transform. 43 | */ 44 | add (other) { 45 | return new Transform(this.vector.add(other.vector), this.rotation + other.rotation) 46 | } 47 | 48 | toString () { return `Transform (vector: ${this.vector}, rotation: ${this.rotation})` } 49 | } 50 | -------------------------------------------------------------------------------- /assets/stylesheets/_type.scss: -------------------------------------------------------------------------------- 1 | @charset 'UTF-8'; 2 | 3 | @import url('https://fonts.googleapis.com/css?family=Josefin+Sans:300,400,700'); 4 | 5 | @font-face { 6 | font-display: block; 7 | font-family: 'icons'; 8 | font-style: normal; 9 | font-weight: normal; 10 | src: url('../fonts/icons.woff2') format('woff2'), 11 | url('../fonts/icons.woff') format('woff'); 12 | } 13 | 14 | [class^="_icon-"]:before, 15 | [class*=" _icon-"]:before { 16 | font-family: 'icons' !important; 17 | font-style: normal !important; 18 | font-weight: normal !important; 19 | font-variant: normal !important; 20 | text-transform: none !important; 21 | speak: none; 22 | line-height: 1; 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | .icon-facebook:before { content: '\61'; } 28 | .icon-github:before { content: '\62'; } 29 | .icon-twitter:before { content: '\63'; } 30 | .icon-help:before { content: '\64'; } 31 | .icon-bell:before { content: '\65'; } 32 | .icon-bell-slash:before { content: '\66'; } 33 | 34 | html { 35 | -moz-osx-font-smoothing: grayscale; 36 | -webkit-font-smoothing: antialiased; 37 | font-family: 'Josefin Sans', sans-serif; 38 | font-size: 16px; 39 | letter-spacing: 0.125rem; 40 | line-height: 1.5; 41 | } 42 | 43 | h1, h2, h3, h4, h5, h6 { 44 | font-weight: 400; 45 | margin-top: 0; 46 | margin-bottom: 0.5rem; 47 | text-transform: uppercase; 48 | } 49 | 50 | h1 { 51 | font-size: 4rem; 52 | letter-spacing: 0.5rem; 53 | } 54 | 55 | h2 { 56 | font-size: 2rem; 57 | } 58 | 59 | h3 { font-size: 1.75rem; } 60 | h4 { font-size: 1.5rem; } 61 | h5 { font-size: 1.25rem; } 62 | h6 { font-size: 1rem; } 63 | 64 | p { 65 | margin-top: 0; 66 | margin-bottom: 1rem; 67 | } 68 | 69 | dl { 70 | display: flex; 71 | flex-wrap: wrap; 72 | margin-top: 0; 73 | margin-bottom: 0.5rem; 74 | 75 | dt { 76 | flex: 0 0 25%; 77 | max-width: 25%; 78 | } 79 | 80 | dd { 81 | flex: 0 0 75%; 82 | margin-bottom: 0.5rem; 83 | margin-left: 0; 84 | max-width: 75%; 85 | } 86 | } 87 | 88 | a { 89 | color: inherit; 90 | text-decoration: none; 91 | border-bottom: 1px solid; 92 | } 93 | -------------------------------------------------------------------------------- /src/views/HelpView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import SocialView from './SocialView' 4 | import styles from '../../assets/stylesheets/styles.scss' 5 | 6 | export default ({ bus }) => 7 |
    bus.next('pause')}> 8 |
    9 |

    Tetris

    10 | 11 |

    How to Play

    12 | 13 |

    The goal of Tetris is to score as many points as possible by 14 | clearing horizontal lines of blocks. The player must rotate, move, and 15 | drop the falling tetriminos inside the playfield. Lines are cleared when 16 | they are filled with blocks and have no empty spaces.

    17 | 18 |

    As lines are cleared, the level increases and tetriminos fall 19 | faster, making the game progressively more challenging. If the blocks 20 | land above the top of the playfield, then the game is over.

    21 | 22 |
    23 |
    LEFT
    24 |
    Move the falling tetromino left.
    25 | 26 |
    RIGHT
    27 |
    Move the falling tetromino right.
    28 | 29 |
    DOWN
    30 |
    Move the falling tetromino down (soft drop).
    31 | 32 |
    Z
    33 |
    Rotate the falling tetromino left.
    34 | 35 |
    X/UP
    36 |
    Rotate the falling tetromino right.
    37 | 38 |
    C
    39 |
    Store the falling tetromino for later use.
    40 | 41 |
    SPACE
    42 |
    Drop the falling tetromino to the bottom of the playfield and lock it immediately (hard drop).
    43 | 44 |
    RETURN
    45 |
    Drop the falling tetromino to the bottom of the playfield, but don't lock it (firm drop).
    46 | 47 |
    H
    48 |
    Toggle the help screen.
    49 | 50 |
    M
    51 |
    Toggle the game audio.
    52 |
    53 | 54 |

    Credits

    55 | 56 |

    Made with love by Josh Bassett, 2018.

    57 | 58 |

    Special thanks to Michael Koukoullis for inspiring me to work on Tetris in the first place. This work is based on a project we started, but never finished.

    59 | 60 |
    61 | 62 |
    63 |
    64 |
    65 | -------------------------------------------------------------------------------- /src/Playfield.js: -------------------------------------------------------------------------------- 1 | import { any, chunkBy, compose, copy, difference, fold, sortBy, set, union, whereAny } from 'fkit' 2 | 3 | const WIDTH = 10 4 | const HEIGHT = 20 5 | 6 | /** 7 | * Sorts and groups blocks by row. 8 | */ 9 | const groupBlocksByRow = compose( 10 | chunkBy((a, b) => a.position.y === b.position.y), 11 | sortBy((a, b) => a.position.y - b.position.y) 12 | ) 13 | 14 | /** 15 | * Returns true if the given row is complete, false otherwise. 16 | */ 17 | const isComplete = row => row.length === WIDTH 18 | 19 | /** 20 | * The playfield is the grid in which the tetrominoes fall. 21 | */ 22 | export default class Playfield { 23 | constructor () { 24 | this.blocks = [] 25 | } 26 | 27 | /** 28 | * Collides the given blocks with the playfield. 29 | * 30 | * Returns true if a block collides with (or is outside) the playfield, false 31 | * otherwise. 32 | * 33 | * @param blocks An array of blocks. 34 | * @returns A boolean value. 35 | */ 36 | collide (blocks) { 37 | const collideBlock = b => this.blocks.some(a => 38 | a.position.isEqual(b.position) 39 | ) 40 | const isOutside = b => b.position.x < 0 || b.position.x >= WIDTH || b.position.y < 0 || b.position.y >= HEIGHT + 2 41 | return blocks.some(whereAny([collideBlock, isOutside])) 42 | } 43 | 44 | /** 45 | * Locks the given blocks into the playfield. 46 | * 47 | * @param blocks An array of blocks. 48 | * @returns A new playfield. 49 | */ 50 | lock (blocks) { 51 | blocks = union(this.blocks, blocks) 52 | return copy(this, { blocks }) 53 | } 54 | 55 | /** 56 | * Removes completed lines from the playfield. Blocks above any cleared rows 57 | * will be moved down. 58 | * 59 | * @returns An object containing the playfield and the number of cleared 60 | * lines. 61 | */ 62 | clearLines () { 63 | const rows = groupBlocksByRow(this.blocks) 64 | let blocks = this.blocks 65 | 66 | const cleared = fold((cleared, row) => { 67 | if (isComplete(row)) { 68 | blocks = difference(blocks, row) 69 | cleared++ 70 | } else if (cleared > 0) { 71 | blocks = difference(blocks, row) 72 | const newRow = row.map(block => 73 | set('position', block.position.sub([0, cleared]), block) 74 | ) 75 | blocks = union(blocks, newRow) 76 | } 77 | 78 | return cleared 79 | }, 0, rows) 80 | 81 | return { playfield: copy(this, { blocks }), cleared } 82 | } 83 | 84 | /** 85 | * Returns the blocks at the given positions `ps`. 86 | * 87 | * @param vs An array of vectors. 88 | * @returns An array of blocks. 89 | */ 90 | findBlocks (vs) { 91 | return this.blocks.filter(b => 92 | any(a => a.isEqual(b.position), vs) 93 | ) 94 | } 95 | 96 | toString () { 97 | return `Playfield (blocks: [${this.blocks}])` 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /assets/stylesheets/_game.scss: -------------------------------------------------------------------------------- 1 | @mixin block($color) { 2 | background-image: linear-gradient(to bottom left, lighten($color, 15%), darken($color, 15%)); 3 | 4 | border-top-color: lighten($color, 20%); 5 | border-left-color: darken($color, 10%); 6 | border-bottom-color: darken($color, 20%); 7 | border-right-color: darken($color, 10%); 8 | } 9 | 10 | .cyan { @include block(#00f0f0); } 11 | .blue { @include block(#0000f0); } 12 | .orange { @include block(#f0a000); } 13 | .yellow { @include block(#f0f000); } 14 | .green { @include block(#00f000); } 15 | .purple { @include block(#a000f0); } 16 | .red { @include block(#f00000); } 17 | 18 | .game { 19 | position: relative; 20 | display: flex; 21 | flex-wrap: wrap; 22 | width: 578px; 23 | text-transform: uppercase; 24 | 25 | nav { 26 | margin-top: 4rem; 27 | } 28 | 29 | aside { 30 | width: 150px; 31 | 32 | &.left { 33 | padding-right: 20px; 34 | text-align: right; 35 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); 36 | } 37 | 38 | &.right { 39 | font-size: 2rem; 40 | padding-left: 20px; 41 | text-align: center; 42 | } 43 | } 44 | 45 | .panel { 46 | overflow: hidden; 47 | position: relative; 48 | background: $playfield_color; 49 | border: 4px solid #979797; 50 | border-radius: 8px; 51 | height: 100px; 52 | padding: 1rem 0; 53 | text-align: center; 54 | 55 | .tetromino { 56 | bottom: 30px; 57 | left: 35 + $block_size; 58 | 59 | // Centre the O and I tetrominos. 60 | &.shape-o, &.shape-i { left: 35 + 0.5 * $block_size; } 61 | } 62 | } 63 | 64 | .progress { 65 | margin-top: 4rem; 66 | 67 | h6 { 68 | margin-bottom: 0; 69 | } 70 | 71 | p { 72 | font-size: 1.5rem; 73 | margin-bottom: 2rem; 74 | 75 | &:last-of-type { 76 | margin-bottom: 0; 77 | } 78 | } 79 | } 80 | 81 | .tetrion { 82 | overflow: hidden; 83 | position: relative; 84 | border: 4px solid #979797; 85 | border-radius: 8px; 86 | box-sizing: content-box; 87 | width: 230px; 88 | height: 460px; 89 | } 90 | 91 | .playfield, .tetromino { 92 | position: absolute; 93 | width: 100%; 94 | height: 100%; 95 | margin: 0; 96 | padding: 0; 97 | list-style-type: none; 98 | 99 | li { 100 | position: absolute; 101 | border-style: solid; 102 | border-width: $block_inset; 103 | box-sizing: border-box; 104 | width: $block_size; 105 | height: $block_size; 106 | } 107 | } 108 | 109 | .playfield { 110 | background: $playfield_color; 111 | } 112 | 113 | .ghostPiece { 114 | opacity: 0.25; 115 | } 116 | 117 | .message { 118 | position: absolute; 119 | font-size: 2.5rem; 120 | animation: puff 0.9s ease-out 0.1s both; 121 | top: 0; 122 | right: 0; 123 | bottom: 0; 124 | left: 0; 125 | align-items: center; 126 | display: flex; 127 | justify-content: center; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Bus, Signal } from 'bulb' 3 | import { Keyboard } from 'bulb-input' 4 | import { append, head, tail } from 'fkit' 5 | import { render } from 'react-dom' 6 | 7 | import Game from './Game' 8 | import RootView from './views/RootView' 9 | import log from './log' 10 | 11 | const CLOCK_PERIOD = 10 12 | 13 | const ENTER = 13 14 | const SPACE = 32 15 | const UP = 38 16 | const DOWN = 40 17 | const LEFT = 37 18 | const RIGHT = 39 19 | const C = 67 20 | const H = 72 21 | const M = 77 22 | const X = 88 23 | const Z = 90 24 | 25 | const root = document.getElementById('root') 26 | 27 | const commandSignal = Keyboard 28 | .keys(document) 29 | .stateMachine((_, key, emit) => { 30 | if (key === Z) { 31 | emit.next('rotateLeft') 32 | } else if (key === X) { 33 | emit.next('rotateRight') 34 | } else if (key === UP) { 35 | emit.next('rotateRight') 36 | } else if (key === DOWN) { 37 | emit.next('softDrop') 38 | } else if (key === LEFT) { 39 | emit.next('moveLeft') 40 | } else if (key === RIGHT) { 41 | emit.next('moveRight') 42 | } else if (key === ENTER) { 43 | emit.next('firmDrop') 44 | } else if (key === SPACE) { 45 | emit.next('hardDrop') 46 | } else if (key === C) { 47 | emit.next('hold') 48 | } else if (key === H) { 49 | emit.next('pause') 50 | } else if (key === M) { 51 | emit.next('mute') 52 | } 53 | }) 54 | 55 | const bus = new Bus() 56 | const clockSignal = Signal.periodic(CLOCK_PERIOD).always('tick') 57 | const muted = window.localStorage.getItem('muted') === 'true' 58 | const initialState = { game: new Game(muted), commands: [] } 59 | const stateSignal = bus.scan(reducer, initialState) 60 | 61 | const subscriptions = [ 62 | // Forward events from the clock signal to the bus. 63 | bus.connect(clockSignal), 64 | 65 | // Forward events from the command signal to the bus. 66 | bus.connect(commandSignal), 67 | 68 | // Render the UI whenever the state changes. 69 | stateSignal.subscribe(state => render(, root)) 70 | ] 71 | 72 | if (module.hot) { 73 | module.hot.dispose(() => { 74 | log.info('Unsubscribing...') 75 | subscriptions.forEach(s => s.unsubscribe()) 76 | }) 77 | } 78 | 79 | /** 80 | * Applies an event to yield a new state. 81 | * 82 | * @param state The current state. 83 | * @param event An event. 84 | * @returns A new state. 85 | */ 86 | function reducer (state, event) { 87 | let { game, commands } = state 88 | 89 | if (event === 'tick') { 90 | game = game.tick(CLOCK_PERIOD, head(commands)) 91 | commands = tail(commands) 92 | } else if (event === 'pause') { 93 | game = game.pause() 94 | } else if (event === 'mute') { 95 | game = game.mute() 96 | window.localStorage.setItem('muted', game.muted) 97 | } else if (event === 'restart') { 98 | game = new Game() 99 | } else if (!game.paused) { 100 | commands = append(event, commands) 101 | } 102 | 103 | return { ...state, game, commands } 104 | } 105 | -------------------------------------------------------------------------------- /src/Reward.js: -------------------------------------------------------------------------------- 1 | import Message from './Message' 2 | 3 | /** 4 | * Returns the points earned for clearing the given number of lines. 5 | */ 6 | function calculatePoints (n, tspin) { 7 | switch (n) { 8 | case 4: // tetris 9 | return 800 10 | case 3: // triple 11 | return tspin ? 1600 : 500 12 | case 2: // double 13 | return tspin ? 1200 : 300 14 | case 1: // single 15 | return tspin ? 800 : 100 16 | default: 17 | return tspin ? 400 : 0 18 | } 19 | } 20 | 21 | /** 22 | * Returns the message for the number of lines cleared. 23 | */ 24 | function calculateMessage (n, tspin) { 25 | if (tspin) { 26 | return new Message('tspin') 27 | } else if (n === 4) { 28 | return new Message('tetris') 29 | } 30 | } 31 | 32 | /** 33 | * Represents the reward earned when dropping the falling piece or clearing 34 | * lines. 35 | */ 36 | export default class Reward { 37 | /** 38 | * Returns an empty reward. 39 | */ 40 | static get zero () { 41 | return new Reward(0, 0) 42 | } 43 | 44 | /** 45 | * Rewards a soft drop. 46 | * 47 | * @param level The current level. 48 | * @returns A new reward. 49 | */ 50 | static softDrop (level) { 51 | return new Reward(level, 0) 52 | } 53 | 54 | /** 55 | * Rewards a firm drop. 56 | * 57 | * @param dropped The number of rows the falling piece was dropped. 58 | * @param level The current level. 59 | * @returns A new reward. 60 | */ 61 | static firmDrop (dropped, level) { 62 | return new Reward(dropped * level, 0) 63 | } 64 | 65 | /** 66 | * Rewards a hard drop. 67 | * 68 | * @param dropped The number of rows the falling piece was dropped. 69 | * @param cleared The number of lines cleared. 70 | * @param level The current level. 71 | * @param combo True if the combo bonus should be applied, false otherwise. 72 | * @returns A new reward. 73 | */ 74 | static hardDrop (dropped, cleared, level, combo) { 75 | const multiplier = combo ? 1.5 : 1 76 | const points = ((dropped * 2) + calculatePoints(cleared, false)) * level * multiplier 77 | const message = calculateMessage(cleared, false) 78 | return new Reward(points, cleared, message) 79 | } 80 | 81 | /** 82 | * Rewards clearing the given number of lines. 83 | * 84 | * @param cleared The number of lines cleared. 85 | * @param level The current level. 86 | * @param tspin True if the last transform was a T-spin, false otherwise. 87 | * @param combo True if the combo bonus should be applied, false otherwise. 88 | * @returns A new reward. 89 | */ 90 | static clearLines (cleared, level, tspin, combo) { 91 | const multiplier = combo ? 1.5 : 1 92 | const points = calculatePoints(cleared, tspin) * level * multiplier 93 | const message = calculateMessage(cleared, tspin) 94 | return new Reward(points, cleared, message) 95 | } 96 | 97 | constructor (points, lines, message = null) { 98 | this.points = points 99 | this.lines = lines 100 | this.message = message 101 | } 102 | 103 | toString () { 104 | return `Reward (points: ${this.points}, lines: ${this.lines}, message: ${this.message}` 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Tetromino.js: -------------------------------------------------------------------------------- 1 | import { copy, zip } from 'fkit' 2 | 3 | import Block from './Block' 4 | import SRS from './srs' 5 | import Transform from './Transform' 6 | import Vector from './Vector' 7 | 8 | /** 9 | * A tetromino is a polyomino made of four square blocks. The seven one-sided 10 | * tetrominoes are I, O, T, S, Z, J, and L. 11 | */ 12 | export default class Tetromino { 13 | constructor (shape = 'I') { 14 | this.shape = shape 15 | this.lastTransform = null 16 | this.transform = Transform.zero 17 | this.wasHeld = false 18 | } 19 | 20 | /** 21 | * Returns the color. 22 | */ 23 | get color () { 24 | return SRS[this.shape].color 25 | } 26 | 27 | /** 28 | * Returns the wall kick offsets. 29 | */ 30 | get offsets () { 31 | return SRS[this.shape].offsets 32 | } 33 | 34 | /** 35 | * Returns the blocks for the tetromino. 36 | */ 37 | get blocks () { 38 | if (!this._blocks) { 39 | const positions = SRS[this.shape].positions[this.transform.rotation] 40 | this._blocks = positions.map(position => { 41 | return new Block(this.transform.vector.add(position), this.color) 42 | }) 43 | } 44 | 45 | return this._blocks 46 | } 47 | 48 | get transform () { 49 | return this._transform 50 | } 51 | 52 | set transform (t) { 53 | this._transform = t 54 | 55 | // Clear cached blocks. 56 | this._blocks = null 57 | } 58 | 59 | /** 60 | * Returns true if the given transform `t` can be applied to the tetromino, 61 | * false otherwise. 62 | * 63 | * @param c A collision function. 64 | * @returns A boolean. 65 | */ 66 | canApplyTransform (t, c) { 67 | return !c(this.applyTransformWithoutCollisions(t)) 68 | } 69 | 70 | /** 71 | * Resets the tetromino transform and marks it as held. 72 | * 73 | * @returns A new tetromino. 74 | */ 75 | hold () { 76 | return copy(this, { transform: Transform.zero, wasHeld: true }) 77 | } 78 | 79 | /** 80 | * Moves the tetromino to the spawn position. 81 | * 82 | * @returns A new tetromino. 83 | */ 84 | spawn () { 85 | const transform = new Transform(SRS[this.shape].spawn) 86 | return copy(this, { transform }) 87 | } 88 | 89 | /** 90 | * Drops the tetromino down as far as it can go without colliding. 91 | * 92 | * @param c A collision function. 93 | * @returns A new tetromino. 94 | */ 95 | drop (c) { 96 | let t = Transform.zero 97 | 98 | while (true) { 99 | const u = t.add(Transform.down) 100 | if (!this.canApplyTransform(u, c)) { break } 101 | t = u 102 | } 103 | 104 | return this.applyTransformWithoutCollisions(t) 105 | } 106 | 107 | /** 108 | * Applies the given transform `t` to the tetromino. If there is a collision, 109 | * then the wall kick transforms will attempted before giving up. 110 | * 111 | * @param t A transform. 112 | * @param c A collision function. 113 | * @returns A new tetromino. 114 | */ 115 | applyTransform (t, c) { 116 | // Find the first wall kick that doesn't collide. 117 | const u = this.calculateWallKickTransforms(t) 118 | .find(u => this.canApplyTransform(u, c)) 119 | 120 | return u ? this.applyTransformWithoutCollisions(u) : this 121 | } 122 | 123 | /** 124 | * Applies the given transform `t` to the tetromino without detecting 125 | * collisions. 126 | * 127 | * @param t A transform. 128 | * @returns A new tetromino. 129 | */ 130 | applyTransformWithoutCollisions (t) { 131 | const transform = this.transform.add(t) 132 | return copy(this, { transform, lastTransform: t }) 133 | } 134 | 135 | /** 136 | * Calculates the wall kick transforms for the given transform `t`. These 137 | * transforms should be attempted in order when trying to transform a 138 | * tetromino. 139 | * 140 | * See http://harddrop.com/wiki/SRS#How_Guideline_SRS_Really_Works for details. 141 | * 142 | * @param t A transform. 143 | * @returns An array of transforms. 144 | */ 145 | calculateWallKickTransforms (t) { 146 | const from = this.offsets[this.transform.rotation] 147 | const to = this.offsets[this.transform.add(t).rotation] 148 | return zip(from, to).map(([a, b]) => { 149 | const c = new Vector(a).sub(b) 150 | return t.add(new Transform(c)) 151 | }) 152 | } 153 | 154 | toString () { 155 | return `Tetromino (blocks: [${this.blocks}])` 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/srs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The Super Rotation System (SRS) is the current Tetris Guideline standard for 3 | * how tetrominoes behave, defining where and how the tetrominoes spawn, how 4 | * they rotate, and what wall kicks they can perform. 5 | * 6 | * We have implemented the "true rotation" system. 7 | * 8 | * See http://harddrop.com/wiki/SRS for more details. 9 | */ 10 | export default { 11 | I: { 12 | color: 'cyan', 13 | spawn: [4, 19], 14 | positions: [[[-1, 0], [ 0, 0], [ 1, 0], [ 2, 0]], 15 | [[ 0, -2], [ 0, -1], [ 0, 0], [ 0, 1]], 16 | [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], 17 | [[ 0, -1], [ 0, 0], [ 0, 1], [ 0, 2]]], 18 | offsets: [[[ 0, 0], [-1, 0], [ 2, 0], [-1, 0], [ 2, 0]], 19 | [[-1, 0], [ 0, 0], [ 0, 0], [ 0, 1], [ 0, -2]], 20 | [[-1, 1], [ 1, 1], [-2, 1], [ 1, 0], [-2, 0]], 21 | [[ 0, 1], [ 0, 1], [ 0, 1], [ 0, -1], [ 0, 2]]] 22 | }, 23 | 24 | J: { 25 | color: 'blue', 26 | spawn: [4, 19], 27 | positions: [[[-1, 0], [ 0, 0], [ 1, 0], [-1, 1]], 28 | [[ 0, -1], [ 0, 0], [ 0, 1], [ 1, 1]], 29 | [[ 1, -1], [-1, 0], [ 0, 0], [ 1, 0]], 30 | [[-1, -1], [ 0, -1], [ 0, 0], [ 0, 1]]], 31 | offsets: [[[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 32 | [[ 0, 0], [ 1, 0], [ 1, -1], [ 0, 2], [ 1, 2]], 33 | [[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 34 | [[ 0, 0], [-1, 0], [-1, -1], [ 0, 2], [-1, 2]]] 35 | }, 36 | 37 | L: { 38 | color: 'orange', 39 | spawn: [4, 19], 40 | positions: [[[-1, 0], [ 0, 0], [ 1, 0], [ 1, 1]], 41 | [[ 0, -1], [ 1, -1], [ 0, 0], [ 0, 1]], 42 | [[-1, -1], [-1, 0], [ 0, 0], [ 1, 0]], 43 | [[ 0, -1], [ 0, 0], [-1, 1], [ 0, 1]]], 44 | offsets: [[[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 45 | [[ 0, 0], [ 1, 0], [ 1, -1], [ 0, 2], [ 1, 2]], 46 | [[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 47 | [[ 0, 0], [-1, 0], [-1, -1], [ 0, 2], [-1, 2]]] 48 | }, 49 | 50 | O: { 51 | color: 'yellow', 52 | spawn: [4, 19], 53 | positions: [[[ 0, 0], [ 1, 0], [ 0, 1], [ 1, 1]], 54 | [[ 0, -1], [ 1, -1], [ 0, 0], [ 1, 0]], 55 | [[-1, -1], [ 0, -1], [-1, 0], [ 0, 0]], 56 | [[-1, 0], [ 0, 0], [-1, 1], [ 0, 1]]], 57 | offsets: [[[ 0, 0]], 58 | [[ 0, -1]], 59 | [[-1, -1]], 60 | [[-1, 0]]] 61 | }, 62 | 63 | S: { 64 | color: 'green', 65 | spawn: [4, 19], 66 | positions: [[[-1, 0], [ 0, 0], [ 0, 1], [ 1, 1]], 67 | [[ 1, -1], [ 0, 0], [ 1, 0], [ 0, 1]], 68 | [[-1, -1], [ 0, -1], [ 0, 0], [ 1, 0]], 69 | [[ 0, -1], [-1, 0], [ 0, 0], [-1, 1]]], 70 | offsets: [[[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 71 | [[ 0, 0], [ 1, 0], [ 1, -1], [ 0, 2], [ 1, 2]], 72 | [[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 73 | [[ 0, 0], [-1, 0], [-1, -1], [ 0, 2], [-1, 2]]] 74 | }, 75 | 76 | T: { 77 | color: 'purple', 78 | spawn: [4, 19], 79 | positions: [[[-1, 0], [ 0, 0], [ 1, 0], [ 0, 1]], 80 | [[ 0, -1], [ 0, 0], [ 1, 0], [ 0, 1]], 81 | [[ 0, -1], [-1, 0], [ 0, 0], [ 1, 0]], 82 | [[ 0, -1], [-1, 0], [ 0, 0], [ 0, 1]]], 83 | offsets: [[[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 84 | [[ 0, 0], [ 1, 0], [ 1, -1], [ 0, 2], [ 1, 2]], 85 | [[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 86 | [[ 0, 0], [-1, 0], [-1, -1], [ 0, 2], [-1, 2]]] 87 | }, 88 | 89 | Z: { 90 | color: 'red', 91 | spawn: [4, 19], 92 | positions: [[[ 0, 0], [ 1, 0], [-1, 1], [ 0, 1]], 93 | [[ 0, -1], [ 0, 0], [ 1, 0], [ 1, 1]], 94 | [[ 0, -1], [ 1, -1], [-1, 0], [ 0, 0]], 95 | [[-1, -1], [-1, 0], [ 0, 0], [ 0, 1]]], 96 | offsets: [[[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 97 | [[ 0, 0], [ 1, 0], [ 1, -1], [ 0, 2], [ 1, 2]], 98 | [[ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0], [ 0, 0]], 99 | [[ 0, 0], [-1, 0], [-1, -1], [ 0, 2], [-1, 2]]] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Game.js: -------------------------------------------------------------------------------- 1 | import { copy } from 'fkit' 2 | 3 | import Progress from './Progress' 4 | import Tetrion from './Tetrion' 5 | import { play } from './sound' 6 | 7 | const SPAWN_DELAY = 100 8 | const LOCK_DELAY = 1000 9 | 10 | /** 11 | * The game is a state machine which controls a tetrion. The state is advanced 12 | * by repeatedly calling the `tick` function. 13 | */ 14 | export default class Game { 15 | constructor (muted = false) { 16 | this.time = 0 17 | this.state = 'spawning' 18 | this.paused = false 19 | this.muted = muted 20 | this.tetrion = new Tetrion() 21 | this.spawnTimer = 0 22 | this.lockTimer = 0 23 | this.gravityTimer = 0 24 | this.progress = new Progress() 25 | this.reward = null 26 | } 27 | 28 | get level () { 29 | return this.progress.level 30 | } 31 | 32 | get lines () { 33 | return this.progress.lines 34 | } 35 | 36 | get score () { 37 | return this.progress.score 38 | } 39 | 40 | /** 41 | * Returns true if the game is idle, false otherwise. 42 | */ 43 | get isIdle () { 44 | return this.state === 'idle' 45 | } 46 | 47 | /** 48 | * Returns true if the game is spawning, false otherwise. 49 | */ 50 | get isSpawning () { 51 | return this.state === 'spawning' 52 | } 53 | 54 | /** 55 | * Returns true if the game is locking, false otherwise. 56 | */ 57 | get isLocking () { 58 | return this.state === 'locking' 59 | } 60 | 61 | /** 62 | * Returns true if the game is finished, false otherwise. 63 | */ 64 | get over () { 65 | return this.state === 'finished' 66 | } 67 | 68 | /** 69 | * Returns the gravity delay in milliseconds. 70 | */ 71 | get gravityDelay () { 72 | return Math.round((-333.54 * Math.log(this.level)) + 999.98) 73 | } 74 | 75 | /** 76 | * Increments the game state and applies the given command. 77 | * 78 | * @param delta The time delta. 79 | * @param command The user command. 80 | * @returns A new game. 81 | */ 82 | tick (delta, command) { 83 | if (this.paused) { 84 | return this 85 | } 86 | 87 | const time = this.time + delta 88 | let state = this.state 89 | let tetrion = this.tetrion 90 | let spawnTimer = this.spawnTimer 91 | let lockTimer = this.lockTimer 92 | let gravityTimer = this.gravityTimer 93 | let progress = this.progress 94 | let reward = this.reward 95 | 96 | if (this.isSpawning && time - this.spawnTimer >= SPAWN_DELAY) { 97 | const result = this.tetrion.spawn() 98 | tetrion = result.tetrion 99 | 100 | if (tetrion === this.tetrion) { 101 | this.playSound('gameOver') 102 | state = 'finished' 103 | } else { 104 | state = 'idle' 105 | gravityTimer = time 106 | } 107 | } else if (this.isIdle && time - this.gravityTimer >= this.gravityDelay) { 108 | // Apply gravity. 109 | const result = this.tetrion.moveDown() 110 | tetrion = result.tetrion 111 | reward = result.reward 112 | 113 | this.playSound('moveDown') 114 | 115 | state = 'idle' 116 | gravityTimer = time 117 | 118 | // Moving down failed, start locking. 119 | if (tetrion === this.tetrion) { 120 | state = 'locking' 121 | lockTimer = time 122 | } 123 | } else if (this.isLocking && time - this.lockTimer >= LOCK_DELAY) { 124 | const result = this.tetrion.lock(this.level) 125 | tetrion = result.tetrion 126 | reward = result.reward 127 | 128 | const oldProgress = progress 129 | progress = progress.add(reward) 130 | this.playSound('lock', reward.lines > 0, progress.level > oldProgress.level) 131 | 132 | state = 'spawning' 133 | spawnTimer = time 134 | } else if ((this.isIdle || this.isLocking) && command) { 135 | // Dispatch the command. 136 | const result = this.tetrion[command](this.level) 137 | const oldTetrion = tetrion 138 | tetrion = result.tetrion 139 | reward = result.reward 140 | const oldProgress = progress 141 | progress = progress.add(reward) 142 | 143 | if (tetrion !== oldTetrion) { 144 | this.playSound(command, reward.lines > 0, progress.level > oldProgress.level) 145 | } 146 | 147 | if (!tetrion.fallingPiece) { 148 | // Start spawning if there is no falling piece. 149 | state = 'spawning' 150 | spawnTimer = time 151 | } else if (this.isLocking && tetrion.canMoveDown) { 152 | // Abort locking if the falling piece can move down under gravity. 153 | state = 'idle' 154 | gravityTimer = time 155 | } 156 | } 157 | 158 | return copy(this, { time, state, tetrion, spawnTimer, lockTimer, gravityTimer, progress, reward }) 159 | } 160 | 161 | /** 162 | * Pauses/unpauses the game. 163 | */ 164 | pause () { 165 | return copy(this, { paused: !this.paused }) 166 | } 167 | 168 | /** 169 | * Mutes/unmutes the game audio. 170 | * 171 | * @returns A new game. 172 | */ 173 | mute () { 174 | return copy(this, { muted: !this.muted }) 175 | } 176 | 177 | playSound (command, clearLine = false, levelUp = false) { 178 | if (this.muted) { 179 | // Do nothing. 180 | } else if (levelUp) { 181 | return play('level-up') 182 | } else if (clearLine) { 183 | return play('clear-line') 184 | } else { 185 | switch (command) { 186 | case 'moveLeft': 187 | case 'moveRight': 188 | case 'moveDown': 189 | case 'softDrop': 190 | return play('move') 191 | case 'rotateLeft': 192 | case 'rotateRight': 193 | return play('rotate') 194 | case 'firmDrop': 195 | case 'hardDrop': 196 | return play('drop') 197 | case 'lock': 198 | return play('lock') 199 | case 'hold': 200 | return play('hold') 201 | case 'gameOver': 202 | return play('game-over') 203 | } 204 | } 205 | } 206 | 207 | toString () { 208 | return `Game (state: ${this.state}, lines: ${this.lines}, level: ${this.level}, score: ${this.score}, reward: ${this.reward})` 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Tetrion.js: -------------------------------------------------------------------------------- 1 | import { copy } from 'fkit' 2 | 3 | import Bag from './Bag' 4 | import Playfield from './Playfield' 5 | import Reward from './Reward' 6 | import Tetromino from './Tetromino' 7 | import Transform from './Transform' 8 | import log from './log' 9 | 10 | /** 11 | * A tetrion controls the game state according to the rules of Tetris. 12 | */ 13 | export default class Tetrion { 14 | constructor () { 15 | this.bag = new Bag() 16 | this.playfield = new Playfield() 17 | this.fallingPiece = null 18 | this.ghostPiece = null 19 | this.holdPiece = null 20 | this.nextPiece = null 21 | this.difficult = false 22 | } 23 | 24 | /** 25 | * Returns true if the falling piece can move down, false otherwise. 26 | */ 27 | get canMoveDown () { 28 | return this.fallingPiece.canApplyTransform(Transform.down, this.collision) 29 | } 30 | 31 | /** 32 | * Returns a collision function which collides the given tetromino with the 33 | * playfield. 34 | */ 35 | get collision () { 36 | return tetromino => this.playfield.collide(tetromino.blocks) 37 | } 38 | 39 | /** 40 | * Returns true if the last transform was a T-spin, false otherwise. 41 | */ 42 | get tspin () { 43 | const v = this.fallingPiece.transform.vector 44 | const positions = [v.add([-1, -1]), v.add([1, -1]), v.add([-1, 1]), v.add([1, 1])] 45 | const adjacentBlocks = this.playfield.findBlocks(positions) 46 | return this.fallingPiece.shape === 'T' && 47 | this.fallingPiece.lastTransform && 48 | this.fallingPiece.lastTransform.isRotation && 49 | adjacentBlocks.length >= 3 50 | } 51 | 52 | /** 53 | * Spawns a new falling piece. 54 | * 55 | * @returns A new tetrion. 56 | */ 57 | spawn () { 58 | log.info('spawn') 59 | 60 | let { bag, shape } = this.bag.shift() 61 | const fallingPiece = new Tetromino(shape).spawn() 62 | 63 | if (this.collision(fallingPiece)) { 64 | // We can't span a piece if it will collide. 65 | return { tetrion: this } 66 | } else { 67 | const ghostPiece = fallingPiece.drop(this.collision) 68 | const nextPiece = new Tetromino(bag.next) 69 | return { tetrion: copy(this, { bag, fallingPiece, ghostPiece, nextPiece }) } 70 | } 71 | } 72 | 73 | /** 74 | * Moves the falling piece to the hold position and spawns a new falling 75 | * piece. 76 | * 77 | * @returns A new tetrion. 78 | */ 79 | hold () { 80 | log.info('hold') 81 | 82 | if (this.fallingPiece.wasHeld) { 83 | // We can't hold a piece if the falling piece was previously held. 84 | return { tetrion: this, reward: Reward.zero } 85 | } else if (this.holdPiece) { 86 | // Swap the falling piece with the hold piece. 87 | const fallingPiece = this.holdPiece.spawn() 88 | const ghostPiece = fallingPiece.drop(this.collision) 89 | const holdPiece = this.fallingPiece.hold() 90 | return { tetrion: copy(this, { fallingPiece, ghostPiece, holdPiece }), reward: Reward.zero } 91 | } else { 92 | // Hold the falling piece. 93 | const { bag, shape } = this.bag.shift(this.fallingPiece.shape) 94 | const fallingPiece = new Tetromino(shape).hold().spawn() 95 | const ghostPiece = fallingPiece.drop(this.collision) 96 | const holdPiece = this.fallingPiece.hold() 97 | const nextPiece = new Tetromino(bag.next) 98 | return { tetrion: copy(this, { bag, fallingPiece, ghostPiece, holdPiece, nextPiece }), reward: Reward.zero } 99 | } 100 | } 101 | 102 | /** 103 | * Moves the falling piece left. 104 | * 105 | * @returns A new tetrion. 106 | */ 107 | moveLeft () { 108 | log.info('moveLeft') 109 | return { tetrion: this.transform(Transform.left), reward: Reward.zero } 110 | } 111 | 112 | /** 113 | * Moves the falling piece right. 114 | * 115 | * @returns A new tetrion. 116 | */ 117 | moveRight () { 118 | log.info('moveRight') 119 | return { tetrion: this.transform(Transform.right), reward: Reward.zero } 120 | } 121 | 122 | /** 123 | * Moves the falling piece down. 124 | * 125 | * @returns A new tetrion. 126 | */ 127 | moveDown () { 128 | log.info('moveDown') 129 | return { tetrion: this.transform(Transform.down), reward: Reward.zero } 130 | } 131 | 132 | /** 133 | * Rotates the falling piece left. 134 | * 135 | * @returns A new tetrion. 136 | */ 137 | rotateLeft () { 138 | log.info('rotateLeft') 139 | return { tetrion: this.transform(Transform.rotateLeft), reward: Reward.zero } 140 | } 141 | 142 | /** 143 | * Rotates the falling piece right. 144 | * 145 | * @returns A new tetrion. 146 | */ 147 | rotateRight () { 148 | log.info('rotateRight') 149 | return { tetrion: this.transform(Transform.rotateRight), reward: Reward.zero } 150 | } 151 | 152 | /** 153 | * Moves the falling piece down. 154 | * 155 | * @returns A new tetrion. 156 | */ 157 | softDrop (level) { 158 | log.info('softDrop') 159 | 160 | const tetrion = this.transform(Transform.down) 161 | const reward = tetrion !== this ? Reward.softDrop(level) : Reward.zero 162 | 163 | return { tetrion, reward } 164 | } 165 | 166 | /** 167 | * Moves the falling piece to the bottom of the playfield. 168 | * 169 | * @returns A new tetrion. 170 | */ 171 | firmDrop (level) { 172 | log.info('firmDrop') 173 | 174 | const fallingPiece = this.fallingPiece.drop(this.collision) 175 | const dropped = this.fallingPiece.transform.vector.sub(fallingPiece.transform.vector).y 176 | 177 | return { 178 | tetrion: copy(this, { fallingPiece }), 179 | reward: Reward.firmDrop(dropped, level) 180 | } 181 | } 182 | 183 | /** 184 | * Moves the falling piece to the bottom of the playfield and immediately 185 | * locks it. 186 | * 187 | * @returns A new tetrion. 188 | */ 189 | hardDrop (level) { 190 | log.info('hardDrop') 191 | 192 | const fallingPiece = this.fallingPiece.drop(this.collision) 193 | const dropped = this.fallingPiece.transform.vector.sub(fallingPiece.transform.vector).y 194 | const { playfield, cleared } = this.playfield.lock(fallingPiece.blocks).clearLines() 195 | const difficult = (cleared === 4) 196 | const combo = this.difficult && difficult 197 | 198 | return { 199 | tetrion: copy(this, { playfield, fallingPiece: null, difficult }), 200 | reward: Reward.hardDrop(dropped, cleared, level, combo) 201 | } 202 | } 203 | 204 | /** 205 | * Locks the given tetromino into the playfield and clears any completed 206 | * lines. 207 | * 208 | * @returns A new tetrion. 209 | */ 210 | lock (level) { 211 | log.info('lock') 212 | 213 | if (this.collision(this.fallingPiece)) { 214 | throw new Error('Cannot lock falling piece') 215 | } 216 | 217 | const tspin = this.tspin 218 | const { playfield, cleared } = this.playfield.lock(this.fallingPiece.blocks).clearLines() 219 | const difficult = (cleared === 4) || (cleared > 0 && tspin) 220 | const combo = this.difficult && difficult 221 | 222 | return { 223 | tetrion: copy(this, { playfield, fallingPiece: null, difficult }), 224 | reward: Reward.clearLines(cleared, level, tspin, combo) 225 | } 226 | } 227 | 228 | /** 229 | * Applies the given transform `t` to the falling piece. 230 | * 231 | * @returns A new tetrion. 232 | */ 233 | transform (t) { 234 | const fallingPiece = this.fallingPiece.applyTransform(t, this.collision) 235 | 236 | if (fallingPiece !== this.fallingPiece) { 237 | const ghostPiece = fallingPiece.drop(this.collision) 238 | return copy(this, { fallingPiece, ghostPiece }) 239 | } else { 240 | return this 241 | } 242 | } 243 | 244 | toString () { 245 | return `Tetrion (playfield: ${this.playfield}, fallingPiece: ${this.fallingPiece})` 246 | } 247 | } 248 | --------------------------------------------------------------------------------