type GameState = 'running' | 'paused' | 'not-started' | 'finished'; const nope = (x: never): void => {}; type PieceCell = 0 | 1; type PieceLayout = PieceCell[][]; interface UIElement { render(context: CanvasRenderingContext2D, options: UIElementRenderOptions): void; } interface Dimensions { width: number; height: number; } interface GlobalUIOptions { pixelSize: number; padding: number; canvas: HTMLCanvasElement; context: CanvasRenderingContext2D; gridLines: false | number; borderWidth: number; hud: Dimensions; playArea: Dimensions; } interface UIElementRenderOptions extends GlobalUIOptions { x: number; y: number; } interface PieceTranslation { xDelta: number; yDelta: number; } interface TetrissimoCoordinates { x: number; y: number; } interface RectangleCoordinates extends TetrissimoCoordinates, Dimensions {} type PieceActionType = 'moveLeft' | 'moveRight' | 'moveDown' | 'rotateLeft' | 'rotateRight'; interface PieceAction { type: PieceActionType; } const getSize = (value: number, options: Pick): number => value * options.pixelSize; const drawBorderedRect = ( position: Dimensions & TetrissimoCoordinates, context: CanvasRenderingContext2D, options: UIElementRenderOptions, ): void => { const borderWidth = options.borderWidth; if (!borderWidth) { return; } const actualLineWidth = getSize(borderWidth, options); context.lineWidth = actualLineWidth; context.strokeStyle = 'white'; context.strokeRect( getSize(position.x, options) - Math.round(actualLineWidth / 2), getSize(position.y, options) - Math.round(actualLineWidth / 2), getSize(position.width, options) + actualLineWidth, getSize(position.height, options) + actualLineWidth, ); }; class TetrissimoLevel { public readonly order: number; public readonly movementsPerSecond: number; public static readonly level1 = new TetrissimoLevel(1, 0.5); public static readonly level2 = new TetrissimoLevel(2, 0.75); public static readonly level3 = new TetrissimoLevel(2, 1); public static readonly level4 = new TetrissimoLevel(2, 1.5); public static readonly level5 = new TetrissimoLevel(2, 2); public static readonly level6 = new TetrissimoLevel(2, 3); public static readonly level7 = new TetrissimoLevel(2, 4); public static readonly level8 = new TetrissimoLevel(2, 6); public static readonly level9 = new TetrissimoLevel(2, 8); public static readonly level10 = new TetrissimoLevel(2, 10); public constructor(order: number, speed: number) { this.order = order; this.movementsPerSecond = speed; } } class TetrissimoGame { private startedAt: Date | null = null; private pausedAt: Date | null = null; private finishedAt: Date | null = null; private state: GameState = 'not-started'; private fps = 60; private readonly ui: TetrissimoUI; private lastFrameTimes: number[] = []; private readonly pieces: TetrissimoPiece[] = []; public currentLevel: TetrissimoLevel = TetrissimoLevel.level1; private msUntilNextTick = 0; private lastTick = new Date(); private lastUpdate = new Date(); public constructor(ui: TetrissimoUI) { this.ui = ui; } public getState(): GameState { return this.state; } public setTargetFps(fps: number) { this.fps = fps; } public setLevel(level: TetrissimoLevel) { this.currentLevel = level; } public getCurrentFps(): number { switch (this.state) { case 'running': if (this.lastFrameTimes.length <= 1) { return 0; } const firstFrame = this.lastFrameTimes[0]; const lastFrame = this.lastFrameTimes[this.lastFrameTimes.length - 1]; const elapsedMs = lastFrame - firstFrame; const avgMsPerFrame = (elapsedMs / this.lastFrameTimes.length); return 1000 / avgMsPerFrame; case 'paused': case 'not-started': case 'finished': return 0; default: nope(this.state); throw new Error(`Invalid game state "${this.state}"`); } } public performPieceAction(action: PieceAction) { if (this.state !== 'running') { return; } const piece = this.pieces.filter(piece => piece.state === 'in-play')[0]; if (!piece) { // nothing to do, no pieces in play return; } const movementIncrement = 1; switch (action.type) { case 'moveLeft': this.moveIfValid(piece, -movementIncrement, 0); break; case 'moveRight': this.moveIfValid(piece, movementIncrement, 0); break; case 'moveDown': if (!this.moveIfValid(piece, 0, movementIncrement)) { // make fixed to bottom of play area piece.setFixed(); } break; case 'rotateLeft': // TODO ensure it's a valid move piece.rotateLeft(); break; case 'rotateRight': piece.rotateRight(); break; default: nope(action.type); throw new Error(`invalid action "${action.type}"`); } } public start() { this.pausedAt = null; switch (this.state) { case 'running': // no-op break; case 'paused': case 'not-started': this.lastTick = new Date(); this.lastUpdate = new Date(); this.startedAt = new Date(); this.state = 'running'; this.update(); break; case 'finished': throw new Error('Cannot start a finished game'); default: nope(this.state); throw new Error(`Invalid game state "${this.state}"`); } } public pause() { switch (this.state) { case 'running': case 'paused': this.pausedAt = new Date(); this.state = 'paused'; this.lastFrameTimes = []; break; case 'not-started': case 'finished': throw new Error(`Cannot pause game while it's in state "${this.state}"`); default: nope(this.state); throw new Error(`Invalid game state "${this.state}"`); } } public update(): void { const tickStart = Date.now(); this.lastFrameTimes.push(tickStart); if (this.lastFrameTimes.length > 20) { this.lastFrameTimes.shift(); } switch (this.state) { case 'running': this.performUpdate(); break; case 'paused': case 'not-started': case 'finished': throw new Error(`Cannot advance game while in state "${this.state}"`); default: nope(this.state); throw new Error(`Invalid game state "${this.state}"`); } const elapsed = Date.now() - tickStart; const nextTickTimeout = Math.max(0, (1000 / this.fps) - elapsed); setTimeout(() => { switch (this.state) { case 'running': this.update(); break; case 'paused': case 'not-started': case 'finished': break; default: nope(this.state); throw new Error(`Invalid game state "${this.state}"`); } }, nextTickTimeout); } private performUpdate(): void { const sinceLastUpdate = Date.now() - this.lastUpdate.getTime(); this.msUntilNextTick -= sinceLastUpdate; this.lastUpdate = new Date(); if (this.msUntilNextTick <= 0) { this.tick(); } // detect collision // is every column filled? remove column // is every row filled? end game ui.update(); } private moveIfValid(piece: TetrissimoPiece, xDelta: number, yDelta: number): boolean { const translation: PieceTranslation = { xDelta, yDelta, }; if (this.ui.canMovePiece(piece, translation)) { piece.move(translation); return true; } return false; }; private tick(): void { const inPlayPieces = this.pieces.filter(piece => piece.state === 'in-play'); if (!inPlayPieces.length) { const newPiece = TetrissimoPiece.T.clone(); newPiece.state = 'in-play'; this.pieces.push(newPiece); this.ui.addPiece(newPiece); } else { inPlayPieces.forEach((piece) => { if (!this.moveIfValid(piece, 0, 1)) { piece.setFixed(); } }); } this.msUntilNextTick = Math.round(1000 / this.currentLevel.movementsPerSecond); this.lastTick = new Date(); } } class TetrissimoUI { public readonly playArea: TetrissimoPlayArea; private readonly pieces: Array> = []; public readonly nextBox: TetrissimoNextBox; public readonly hud: TetrissimoHUD; public readonly options: GlobalUIOptions; public constructor( canvas: HTMLCanvasElement, playArea: TetrissimoPlayArea, // nextBox: TetrissimoNextBox, hud: TetrissimoHUD, ) { this.playArea = playArea; // this.nextBox = nextBox; this.hud = hud; const context = canvas.getContext('2d'); if (!context) { throw new Error('failed to get 2d context'); } this.options = { borderWidth: 1, canvas: canvas, context, gridLines: 4, hud: { width: 32, height: 12, }, playArea: { width: 68, height: 128, }, padding: 2, pixelSize: 4, }; } public setOptions(options: Omit) { Object.keys(options).forEach((key) => { this.options[key] = options[key]; }); } public addPiece(piece: TetrissimoPiece) { this.pieces.push(piece); } private getPiecePosition(piece: Readonly): TetrissimoCoordinates { const origin = this.getPieceOrigin(piece); const offset = piece.getOffset(); return { x: origin.x + offset.x, y: origin.y + offset.y, }; } public canMovePiece(piece: Readonly, delta: PieceTranslation): boolean { // check each block in layout's edge to see if it's in a valid area?s const layout = piece.getLayout(); const position = this.getPiecePosition(piece); for (let i = 0; i < layout.length; i++) { const blockTopEdge = position.y + (i * TetrissimoPiece.blockLength) + (delta.yDelta * TetrissimoPiece.blockLength); for (let j = 0; j < layout[i].length; j++) { if (layout[i][j] === 0) { continue; } const blockLeftEdge = position.x + (j * TetrissimoPiece.blockLength) + (delta.xDelta * TetrissimoPiece.blockLength); const isCollision = this.hasCollision({ x: blockLeftEdge, y: blockTopEdge, width: TetrissimoPiece.blockLength, height: TetrissimoPiece.blockLength, }); if (isCollision) { return false; } } } return true; } private hasCollision(rect: RectangleCoordinates): boolean { const playAreaLeftEdge = this.options.padding; const playAreaRightEdge = playAreaLeftEdge + this.options.playArea.width; const playAreaTopEdge = this.options.padding; const playAreaBottomEdge = playAreaTopEdge + this.options.playArea.height; const leftEdge = rect.x; const rightEdge = rect.x + rect.width; const topEdge = rect.y; const bottomEdge = rect.y + rect.height; const isInPlayArea = leftEdge >= playAreaLeftEdge && rightEdge <= playAreaRightEdge && bottomEdge <= playAreaBottomEdge && topEdge >= playAreaTopEdge; if (!isInPlayArea) { return true; } const fixedPieces = this.pieces.filter(piece => piece.state === 'fixed'); for (const piece of fixedPieces) { const position = this.getPiecePosition(piece); const layout = piece.getLayout(); for (let i = 0; i < layout.length; i++) { const blockTopEdge = position.y + (i * TetrissimoPiece.blockLength); const blockBottomEdge = blockTopEdge + TetrissimoPiece.blockLength; for (let j = 0; j < layout[i].length; j++) { if (layout[i][j] === 0) { continue; } const blockLeftEdge = position.x + (j * TetrissimoPiece.blockLength); const blockRightEdge = blockLeftEdge + TetrissimoPiece.blockLength; const isCollision = leftEdge >= blockLeftEdge && rightEdge <= blockRightEdge && bottomEdge <= blockBottomEdge && topEdge >= blockTopEdge; if (isCollision) { return true; } } } } return false; } public update() { const options = this.options; const context = this.options.context; context.clearRect(0, 0, canvas.width, canvas.height); // fill background context.fillStyle = 'rgb(224,158,107)'; context.fillRect(0, 0, canvas.width, canvas.height); // draw some grid lines if (typeof(options.gridLines) === 'number') { context.strokeStyle = 'rgba(255, 255, 255, 0.25)'; context.lineWidth = 1; const increment = options.pixelSize * options.gridLines; for (let i = 0; i < options.canvas.width; i += increment) { // draw vertical line context.beginPath(); context.moveTo(i, 0); context.lineTo(i, options.canvas.height); context.stroke(); } for (let i = 0; i < options.canvas.height; i += increment) { // draw horizontal lines context.beginPath(); context.moveTo(0, i); context.lineTo(options.canvas.width, i); context.stroke(); } } const totalWidth = Math.floor(canvas.width / options.pixelSize); const padding = options.padding; // draw HUD in top right corner this.hud.render(context, { ...options, x: totalWidth - padding - options.hud.width, y: padding, }); // draw play area this.playArea.render(context, { ...options, x: padding, y: padding, }); this.pieces.forEach((piece, i) => { piece.render(context, { ...options, ...this.getPieceOrigin(piece), }); }); } private getPieceOrigin(piece: Readonly): TetrissimoCoordinates { const length = TetrissimoPiece.blockLength; // TODO rotation will cause problems, need a "PieceDefinition" or something with a static layout let translateX = Math.floor((piece.getBlockWidth() * length) / 2); // we can only perform movements in multiples of the blockLength, so we must clamp it // to ensure it's not offset translateX -= (translateX % length); const padding = this.options.padding; return { x: padding + Math.round(this.options.playArea.width / 2) - translateX, y: padding, }; } } class TetrissimoNextBox implements UIElement { public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) { } } class TetrissimoHUD implements UIElement { public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) { const position = { x: options.x, y: options.y, width: options.hud.width, height: options.hud.height, }; drawBorderedRect(position, context, options); } } class TetrissimoPlayArea implements UIElement { public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) { const position = { x: options.x, y: options.y, width: options.playArea.width, height: options.playArea.height, }; drawBorderedRect(position, context, options); } } type PieceState = 'in-play' | 'fixed'; class TetrissimoPiece implements UIElement { public readonly name: string; private readonly originalLayout: Readonly; private layout: PieceLayout; private offset: TetrissimoCoordinates = { x: 0, y: 0 }; private readonly color: string; public static readonly blockLength = 4; public state: PieceState = 'in-play'; public static readonly J = new TetrissimoPiece( 'J', [ [0, 1], [0, 1], [1, 1], ], 'black', ); public static readonly S: TetrissimoPiece = new TetrissimoPiece( 'S', [ [0, 1, 1], [1, 1, 0], ], 'green', ); public static readonly T: TetrissimoPiece = new TetrissimoPiece( 'T', [ [1, 1, 1], [0, 1, 0], ], 'blue', ); public static readonly Z: TetrissimoPiece = new TetrissimoPiece( 'Z', [ [1, 1, 0,], [0, 1, 1,], ], 'yellow', ); public static readonly I: TetrissimoPiece = new TetrissimoPiece( 'I', [ [1, 1, 1, 1], ], 'magenta', ); public static readonly L: TetrissimoPiece = new TetrissimoPiece( 'L', [ [1], [1], [1, 1], ], 'cyan', ); public static readonly O: TetrissimoPiece = new TetrissimoPiece( 'O', [ [1, 1], [1, 1], ], 'red', ); public constructor(name: string, layout: PieceLayout, color: string) { this.name = name; this.originalLayout = layout.map(row => row.concat([])); this.layout = layout.concat([]); this.color = color; } public getOffset(): Readonly { return this.offset; } public getLayout(): Readonly { return this.layout; } public move(translation: PieceTranslation) { if (this.state !== 'in-play') { return; } this.offset.x += (translation.xDelta * TetrissimoPiece.blockLength); this.offset.y += (translation.yDelta * TetrissimoPiece.blockLength); } public rotateLeft() { if (this.state !== 'in-play') { return; } // clockwise turn const newLayout: PieceLayout = []; for (let i = 0; i < this.layout.length; i++) { const col = this.layout.length - 1 - i; for (let j = 0; j < this.layout[i].length; j++) { const row = j; if (!newLayout[row]) { newLayout[row] = []; } newLayout[row][col] = this.layout[i][j]; } } this.layout = newLayout; } public rotateRight() { if (this.state !== 'in-play') { return; } const newLayout: PieceLayout = []; for (let i = 0; i < this.layout.length; i++) { const col = i; for (let j = 0; j < this.layout[i].length; j++) { const row = this.layout[i].length - 1 - j; if (!newLayout[row]) { newLayout[row] = []; } newLayout[row][col] = this.layout[i][j]; } } this.layout = newLayout; } public setFixed() { this.state = 'fixed'; } public getBlockWidth(): number { return this.layout .map(arr => arr.filter(value => value === 1).length) .reduce((max, len) => Math.max(max, len), 0); } public getBlockHeight(): number { const cols: PieceCell[][] = []; for (let i = 0; i < this.layout.length; i++) { for (let j = 0; j < this.layout[i].length; j++) { if (!cols[j]) { cols[j] = []; } cols[j].push(this.layout[i][j]); } } return cols .map(arr => arr.filter(value => value === 1).length) .reduce((max, len) => Math.max(max, len), 0); } public clone(): TetrissimoPiece { return new TetrissimoPiece(this.name, this.layout, this.color); } public render(context: CanvasRenderingContext2D, options: UIElementRenderOptions) { this.layout.forEach((columns, rowNum) => { columns.forEach((value, colNum) => { switch (value) { case 0: break; case 1: context.fillStyle = this.color; const length = TetrissimoPiece.blockLength; const offsetX = this.offset.x; const offsetY = this.offset.y; const x = options.x + offsetX + (colNum * length); const y = options.y + offsetY + (rowNum * length); const actualLength = getSize(length, options); context.fillRect(getSize(x, options), getSize(y, options), actualLength, actualLength); break; default: nope(value); throw new Error(`Invalid piece cell value "${value}"`); } }); }); } } // --------------------------------------------- const hud = new TetrissimoHUD(); const playArea = new TetrissimoPlayArea(); const canvas = document.getElementsByTagName('canvas')[0]; if (!canvas) { throw new Error('no canvas'); } const ui = new TetrissimoUI(canvas, playArea, hud); const game = new TetrissimoGame(ui);