typetris
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()
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()