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(HEIGHT).fill([]) .map(() => Array(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 = `` document!.querySelector('.typetris')!.innerHTML = template const canvas = 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()