typetris

Download
const primaryColor = '#CE5127'
const primaryColorLight = ' #E98A70'
const secondaryColor = '#27A2CC'
const secondaryColorLight = '#86CFE4'
const textColor = '#4B4B4B'
const backgroundColor = '#F9F4F2'

const WIDTH = 10
const HEIGHT = 20           // TODO 24
const CELLSIZE = 16

// colors are material 700
const blocks = [
    {
        color: '#0097A7',           // cyan
        coords: [
            [[0, 1], [1, 1], [2, 1], [3, 1]],
            [[1, 0], [1, 1], [1, 2], [1, 3]],
            [[0, 1], [1, 1], [2, 1], [3, 1]],
            [[1, 0], [1, 1], [1, 2], [1, 3]]],
        shape: 'I'
    }, {
        color: '#7B1FA2',           // purple
        coords: [
            [[0, 1], [1, 1], [2, 1], [2, 2]],
            [[1, 0], [1, 1], [1, 2], [2, 0]],
            [[0, 1], [0, 2], [1, 2], [2, 2]],
            [[1, 2], [2, 0], [2, 1], [2, 2]]],
        shape: 'J'
    }, {
        color: '#F57C00',           // orange
        coords: [
            [[0, 1], [0, 2], [1, 1], [2, 1]],
            [[1, 0], [1, 1], [1, 2], [2, 2]],
            [[0, 2], [1, 2], [2, 1], [2, 2]],
            [[1, 0], [2, 0], [2, 1], [2, 2]]],
        shape: 'L'
    }, {
        color: '#FBC02D',           // yellow
        coords: [
            [[1, 1], [1, 2], [2, 1], [2, 2]],
            [[1, 1], [1, 2], [2, 1], [2, 2]],
            [[1, 1], [1, 2], [2, 1], [2, 2]],
            [[1, 1], [1, 2], [2, 1], [2, 2]]],
        shape: 'O'
    }, {
        color: '#388E3C',           // green
        coords: [
            [[0, 2], [1, 1], [1, 2], [2, 1]],
            [[1, 0], [1, 1], [2, 1], [2, 2]],
            [[0, 2], [1, 1], [1, 2], [2, 1]],
            [[1, 0], [1, 1], [2, 1], [2, 2]]],
        shape: 'S'
    }, {
        color: '#1976D2',           // blue
        coords: [
            [[0, 1], [1, 1], [1, 2], [2, 1]],
            [[1, 0], [1, 1], [1, 2], [2, 1]],
            [[0, 2], [1, 1], [1, 2], [2, 2]],
            [[1, 1], [2, 0], [2, 1], [2, 2]]],
        shape: 'T'
    }, {
        color: '#D32F2F',           // red
        coords: [
            [[0, 1], [1, 1], [1, 2], [2, 2]],
            [[1, 1], [1, 2], [2, 0], [2, 1]],
            [[0, 1], [1, 1], [1, 2], [2, 2]],
            [[1, 1], [1, 2], [2, 0], [2, 1]]],
        shape: 'Z'
    }
]

// IRenderer provides primitives to render blocks and score statistics
interface IRenderer {
    draw(x: number, y: number, color: string): void
    score(score: number, lines: number): void
}

class CanvasRenderer implements IRenderer {
    private context: CanvasRenderingContext2D | null

    constructor(canvas: HTMLCanvasElement) {
        canvas.setAttribute('width', '' + (WIDTH + 6) * CELLSIZE)
        canvas.setAttribute('height', '' + HEIGHT * CELLSIZE)

        this.context = canvas.getContext('2d')
        this.context!.font = '18px arial'
        this.context!.textAlign = 'center'
    }

    public draw(x: number, y: number, color: string): void {
        this.context!.fillStyle = color
        this.context!.fillRect(x * CELLSIZE, y * CELLSIZE, CELLSIZE, CELLSIZE)
    }

    public score(score: number, lines: number): void {
        // clear background
        this.context!.fillStyle = secondaryColor
        this.context!.fillRect(WIDTH * CELLSIZE, 0, WIDTH * CELLSIZE, HEIGHT * CELLSIZE)

        // draw text
        this.context!.save()
        this.context!.fillStyle = 'white'
        this.context!.fillText('Score', CELLSIZE * 13, CELLSIZE * 11)
        this.context!.fillText(score.toString(), CELLSIZE * 13, CELLSIZE * 13)
        this.context!.fillText('Lines', CELLSIZE * 13, CELLSIZE * 17)
        this.context!.fillText(lines.toString(), CELLSIZE * 13, CELLSIZE * 19)
    }
}

class Block {
    public coords: number[][][] = [[[]]]
    public color: string = ''
    public shape: string = ''
}

class Tetris {
    private bucket: number[][]

    private block: Block
    private nextBlock: Block

    private timer = 0
    private pause = false

    // player variables
    private rotation = 0

    private column: number = 0
    private row: number = 0

    private score = 0
    private lines = 0

    constructor(private renderer: IRenderer) {
        this.block = this.randomBlock()
        this.nextBlock = this.randomBlock()

        this.bucket = this.initBucket()
    }

    public newGame(): void {
        this.score = 0
        this.lines = 0

        this.bucket = this.initBucket()
        this.newBlock()

        window.addEventListener('keydown', (event: KeyboardEvent) => {
            if (event.defaultPrevented) {
                return
            }

            if (event.code == 'KeyP') {
                this.pause = !this.pause
                if (!this.pause) {
                    this.fall()     // anti-cheat
                }
                event.preventDefault()
            }

            if(this.pause) {
                return
            }

            switch (event.code) {
                case 'ArrowLeft': case 'KeyJ':
                    this.move(-1, 0, this.rotation)
                    event.preventDefault()
                    break

                case 'ArrowUp': case 'KeyK':
                    this.move(0, 0, (this.rotation + 1) % 4)
                    event.preventDefault()
                    break

                case 'ArrowRight': case 'KeyL':
                    this.move(1, 0, this.rotation)
                    event.preventDefault()
                    break

                case 'Space':
                    this.drop()
                    event.preventDefault()
                    break

                case 'ArrowDown': case 'KeyI': case 'KeyM':
                    this.fall()
                    event.preventDefault()
                    break
            }
        }, true)
    }

    public newBlock(): void {
        // clear preview
        this.draw(WIDTH + 1, 0, this.nextBlock, 0, secondaryColorLight)

        const clearedLines = this.sweepBucket()
        this.redrawBucket()

        // stats
        const bonus = [0, 100, 300, 700, 1500]
        this.lines += clearedLines
        this.score += bonus[clearedLines]
        this.renderer.score(this.score, this.lines)

        // show preview
        this.block = this.nextBlock
        this.nextBlock = this.randomBlock()
        this.draw(WIDTH + 1, 0, this.nextBlock, 0)

        clearInterval(this.timer)
        this.timer = setInterval(() => this.fall(), 650 - this.lines)

        this.column = 3
        this.row = 0
        this.rotation = 0

        if (!this.move(0, 0, this.rotation)) {
            clearInterval(this.timer)

            this.gameOver()
        }
    }

    public move(deltaColumn: number, deltaRow: number, newRotation: number) {
        const canMove = this.testBlock(deltaColumn, deltaRow, this.block, newRotation)
        if (canMove) {
            // clear previous block
            this.draw(this.column, this.row, this.block, this.rotation, backgroundColor)

            this.column += deltaColumn
            this.row += deltaRow
            this.rotation = newRotation

            // draw block
            this.draw(this.column, this.row, this.block, this.rotation)
        }

        return canMove
    }

    public fall() {
        if (this.pause) {
            return
        }
        if (!this.move(0, 1, this.rotation)) {
            // block lands
            this.setBuffer(blocks.indexOf(this.block), this.column, this.row, this.block, this.rotation)
            this.newBlock()
        }
    }

    public drop() {
        clearInterval(this.timer)
        this.timer = setInterval(() => this.fall(), 9)
        this.score += (HEIGHT - this.row)
        this.renderer.score(this.score, this.lines)
    }

    private draw(column: number, row: number, block: Block, rotation: number, color: string = block.color) {
        for (const { x, y } of this.coords(block, rotation, column, row)) {
            this.renderer.draw(x, y, color)
        }
    }

    private initBucket(): number[][] {
        return Array<number[]>(HEIGHT).fill([])
            .map(() => Array<number>(WIDTH).fill(-1))
    }

    private setBuffer(set: number, column: number, row: number, block: Block, rotation: number) {
        for (const { x, y } of this.coords(block, rotation, column, row)) {
            this.bucket[y][x] = set
        }
    }

    // testBlocks tests if a block can be placed and returns true if so, false otherwise
    private testBlock(deltaColumn: number, deltaRow: number, block: Block, rotation: number): boolean {
        const predicate = ({ x, y }: { x: number, y: number }) =>
            x >= 0 && x < WIDTH && y < HEIGHT && this.bucket[y][x] < 0

        return [...this.coords(block, rotation, this.column + deltaColumn, this.row + deltaRow)]
            .every(predicate)
    }

    private *coords(block: Block, rotation: number, column: number, row: number): IterableIterator<{ x: number, y: number }> {
        const coords = block.coords[rotation]

        for (const coord of coords) {
            const [x, y] = [column + coord[0], row + coord[1]]
            yield { x, y }
        }
    }

    private randomBlock(): Block {
        return blocks[Math.floor(Math.random() * blocks.length)]
    }

    private sweepBucket(): number {
        let linesCleared = 0

        for (const [rowIndex, row] of this.bucket.entries()) {
            const full = row.every(cell => cell >= 0)
            if (full) {
                this.bucket.splice(rowIndex, 1)
                this.bucket.unshift(new Array(WIDTH).fill(-1))
                linesCleared++
            }
        }

        return linesCleared
    }

    private redrawBucket(): void {
        this.bucket.forEach((row, y) => {
            row.forEach((cell, x) => {
                const color = cell >= 0 ? blocks[cell].color : backgroundColor
                this.renderer.draw(x, y, color)
            })
        })
    }

    private gameOver() {
        alert('Game over :-(')
        this.newGame()
    }
}

class Widget {
    constructor() {
        const template = `<canvas class="bucket"></canvas>`
        document!.querySelector('.typetris')!.innerHTML = template

        const canvas = <HTMLCanvasElement>document.querySelector('.typetris canvas')
        const renderer = new CanvasRenderer(canvas)

        const tetris = new Tetris(renderer)
        tetris.newGame()
    }
}

// tslint:disable-next-line: no-unused-expression
new Widget()