├── src ├── assets │ ├── .gitkeep │ ├── share-image.png │ └── share-image-large.png ├── favicon.ico ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── app │ ├── app.component.ts │ ├── app.module.ts │ ├── board.component.html │ ├── game.service.ts │ ├── constants.ts │ ├── piece.component.ts │ └── board.component.ts ├── main.ts ├── styles.scss ├── index.html └── polyfills.ts ├── tsconfig.app.json ├── browserslist ├── tsconfig.json ├── README.md ├── .gitignore ├── package.json └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/ng-tetris/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/assets/share-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/ng-tetris/HEAD/src/assets/share-image.png -------------------------------------------------------------------------------- /src/assets/share-image-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/ng-tetris/HEAD/src/assets/share-image-large.png -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | template: ` 6 | 7 | `, 8 | }) 9 | export class AppComponent { 10 | title = 'ng-tetris'; 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ], 14 | "exclude": [ 15 | "src/test.ts", 16 | "src/**/*.spec.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import { BoardComponent } from './board.component'; 6 | 7 | @NgModule({ 8 | declarations: [AppComponent, BoardComponent], 9 | imports: [BrowserModule], 10 | providers: [], 11 | bootstrap: [AppComponent] 12 | }) 13 | export class AppModule {} 14 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ] 21 | }, 22 | "angularCompilerOptions": { 23 | "fullTemplateTypeCheck": true, 24 | "strictInjectionParameters": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ng-tetris 2 | 3 | Tetris game in Angular. [Play it now!](https://focused-mestorf-930f82.netlify.com/) 4 | 5 | Read the [blog about making the game](https://medium.com/angular-in-depth/game-development-tetris-in-angular-64ef96ce56f7?sk=66ab4b5774919de28eecd3a2662557a4) 6 | 7 | ![tetris picture](src/assets/share-image-large.png) 8 | 9 | ## Development server 10 | 11 | Run `npm start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | * { 3 | font-family: 'Press Start 2P', cursive; 4 | } 5 | 6 | .grid { 7 | display: grid; 8 | grid-template-columns: 320px 200px; 9 | } 10 | 11 | .right-column { 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: space-between; 15 | width: 300px; 16 | } 17 | 18 | .game-board { 19 | border: solid 2px; 20 | } 21 | 22 | .button-container { 23 | display: flex; 24 | flex-direction: row; 25 | justify-content: space-between; 26 | 27 | .play-button { 28 | background-color: #4caf50; 29 | } 30 | 31 | .reset-button { 32 | background-color: red; 33 | } 34 | 35 | .pause-button { 36 | background-color: #4caf50; 37 | 38 | &.button-disabled { 39 | background-color: #cccccc; 40 | } 41 | } 42 | 43 | .button { 44 | font-size: 16px; 45 | padding: 15px 30px; 46 | cursor: pointer; 47 | width: 140px; 48 | border: 1px solid black; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ng-tetris 6 | 7 | 8 | 9 | 10 | 12 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-tetris", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve -o", 7 | "build": "ng build", 8 | "build:prod": "ng build --prod", 9 | "test": "ng test", 10 | "lint": "ng lint" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~9.1.9", 15 | "@angular/common": "~9.1.9", 16 | "@angular/compiler": "~9.1.9", 17 | "@angular/core": "~9.1.9", 18 | "@angular/forms": "~9.1.9", 19 | "@angular/platform-browser": "~9.1.9", 20 | "@angular/platform-browser-dynamic": "~9.1.9", 21 | "@angular/router": "~9.1.9", 22 | "ng-zzfx": "0.0.6", 23 | "rxjs": "~6.5.5", 24 | "tslib": "^1.10.0", 25 | "zone.js": "~0.10.2" 26 | }, 27 | "devDependencies": { 28 | "@angular-devkit/build-angular": "~0.901.7", 29 | "@angular/cli": "~9.1.7", 30 | "@angular/compiler-cli": "~9.1.9", 31 | "@angular/language-service": "~9.1.9", 32 | "@types/node": "^12.11.1", 33 | "ts-node": "~7.0.0", 34 | "tslint": "~5.15.0", 35 | "typescript": "~3.8.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/board.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

TETRIS

6 |

Score: {{ points }}

7 |

Lines: {{ lines }}

8 |

Level: {{ level }}

9 |

High Score:

10 |

{{ highScore }}

11 |

Next Block:

12 | 13 |
14 |
15 | 22 | 29 | 36 |
37 |
38 |
-------------------------------------------------------------------------------- /src/app/game.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { IPiece } from './piece.component'; 3 | import { COLS, ROWS, POINTS } from './constants'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class GameService { 9 | valid(p: IPiece, board: number[][]): boolean { 10 | return p.shape.every((row, dy) => { 11 | return row.every((value, dx) => { 12 | let x = p.x + dx; 13 | let y = p.y + dy; 14 | return ( 15 | this.isEmpty(value) || 16 | (this.insideWalls(x) && 17 | this.aboveFloor(y) && 18 | this.notOccupied(board, x, y)) 19 | ); 20 | }); 21 | }); 22 | } 23 | 24 | isEmpty(value: number): boolean { 25 | return value === 0; 26 | } 27 | 28 | insideWalls(x: number): boolean { 29 | return x >= 0 && x < COLS; 30 | } 31 | 32 | aboveFloor(y: number): boolean { 33 | return y <= ROWS; 34 | } 35 | 36 | notOccupied(board: number[][], x: number, y: number): boolean { 37 | return board[y] && board[y][x] === 0; 38 | } 39 | 40 | rotate(piece: IPiece): IPiece { 41 | let p: IPiece = JSON.parse(JSON.stringify(piece)); 42 | for (let y = 0; y < p.shape.length; ++y) { 43 | for (let x = 0; x < y; ++x) { 44 | [p.shape[x][y], p.shape[y][x]] = [p.shape[y][x], p.shape[x][y]]; 45 | } 46 | } 47 | p.shape.forEach(row => row.reverse()); 48 | return p; 49 | } 50 | 51 | getLinesClearedPoints(lines: number, level: number): number { 52 | const lineClearPoints = 53 | lines === 1 54 | ? POINTS.SINGLE 55 | : lines === 2 56 | ? POINTS.DOUBLE 57 | : lines === 3 58 | ? POINTS.TRIPLE 59 | : lines === 4 60 | ? POINTS.TETRIS 61 | : 0; 62 | 63 | return (level + 1) * lineClearPoints; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/constants.ts: -------------------------------------------------------------------------------- 1 | export const COLS = 10; 2 | export const ROWS = 20; 3 | export const BLOCK_SIZE = 30; 4 | export const LINES_PER_LEVEL = 10; 5 | export const COLORS = [ 6 | 'none', 7 | 'rgba(0, 255, 255)', 8 | 'rgba(0, 0, 255)', 9 | 'rgba(255, 132, 0)', 10 | 'rgba(255, 255, 0)', 11 | 'rgba(0, 255, 0)', 12 | 'rgba(255, 0, 255)', 13 | 'rgba(255, 0, 0)', 14 | ]; 15 | export const COLORSLIGHTER= [ 16 | 'none', 17 | 'rgba(132, 255, 255)', 18 | 'rgba(132, 132, 255)', 19 | 'rgba(255, 195, 132)', 20 | 'rgba(255, 255, 132)', 21 | 'rgba(132, 255, 132)', 22 | 'rgba(255, 132, 255)', 23 | 'rgba(255, 132, 132)', 24 | ]; 25 | export const COLORSDARKER= [ 26 | 'none', 27 | 'rgba(0, 132, 132)', 28 | 'rgba(0, 0, 132)', 29 | 'rgba(132, 65, 0)', 30 | 'rgba(132, 132, 0)', 31 | 'rgba(0, 132, 0)', 32 | 'rgba(132, 0, 132)', 33 | 'rgba(132, 0, 0)', 34 | ]; 35 | export const SHAPES = [ 36 | [], 37 | [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], 38 | [[2, 0, 0], [2, 2, 2], [0, 0, 0]], 39 | [[0, 0, 3], [3, 3, 3], [0, 0, 0]], 40 | [[4, 4], [4, 4]], 41 | [[0, 5, 5], [5, 5, 0], [0, 0, 0]], 42 | [[0, 6, 0], [6, 6, 6], [0, 0, 0]], 43 | [[7, 7, 0], [0, 7, 7], [0, 0, 0]] 44 | ]; 45 | 46 | export class KEY { 47 | static readonly ESC = 27; 48 | static readonly SPACE = 32; 49 | static readonly LEFT = 37; 50 | static readonly UP = 38; 51 | static readonly RIGHT = 39; 52 | static readonly DOWN = 40; 53 | } 54 | 55 | export class POINTS { 56 | static readonly SINGLE = 100; 57 | static readonly DOUBLE = 300; 58 | static readonly TRIPLE = 500; 59 | static readonly TETRIS = 800; 60 | static readonly SOFT_DROP = 1; 61 | static readonly HARD_DROP = 2; 62 | } 63 | 64 | export class LEVEL { 65 | static readonly 0 = 800; 66 | static readonly 1 = 720; 67 | static readonly 2 = 630; 68 | static readonly 3 = 550; 69 | static readonly 4 = 470; 70 | static readonly 5 = 380; 71 | static readonly 6 = 300; 72 | static readonly 7 = 220; 73 | static readonly 8 = 130; 74 | static readonly 9 = 100; 75 | static readonly 10 = 80; 76 | static readonly 11 = 80; 77 | static readonly 12 = 80; 78 | static readonly 13 = 70; 79 | static readonly 14 = 70; 80 | static readonly 15 = 70; 81 | static readonly 16 = 50; 82 | static readonly 17 = 50; 83 | static readonly 18 = 50; 84 | static readonly 19 = 30; 85 | static readonly 20 = 30; 86 | // 29+ is 20ms 87 | } 88 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ng-tetris": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/ng-tetris", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "tsconfig.app.json", 21 | "aot": true, 22 | "assets": [ 23 | "src/favicon.ico", 24 | "src/assets" 25 | ], 26 | "styles": [ 27 | "src/styles.scss" 28 | ], 29 | "scripts": [] 30 | }, 31 | "configurations": { 32 | "production": { 33 | "fileReplacements": [ 34 | { 35 | "replace": "src/environments/environment.ts", 36 | "with": "src/environments/environment.prod.ts" 37 | } 38 | ], 39 | "optimization": true, 40 | "outputHashing": "all", 41 | "sourceMap": false, 42 | "extractCss": true, 43 | "namedChunks": false, 44 | "extractLicenses": true, 45 | "vendorChunk": false, 46 | "buildOptimizer": true, 47 | "budgets": [ 48 | { 49 | "type": "initial", 50 | "maximumWarning": "2mb", 51 | "maximumError": "5mb" 52 | }, 53 | { 54 | "type": "anyComponentStyle", 55 | "maximumWarning": "6kb", 56 | "maximumError": "10kb" 57 | } 58 | ] 59 | } 60 | } 61 | }, 62 | "serve": { 63 | "builder": "@angular-devkit/build-angular:dev-server", 64 | "options": { 65 | "browserTarget": "ng-tetris:build" 66 | }, 67 | "configurations": { 68 | "production": { 69 | "browserTarget": "ng-tetris:build:production" 70 | } 71 | } 72 | }, 73 | "extract-i18n": { 74 | "builder": "@angular-devkit/build-angular:extract-i18n", 75 | "options": { 76 | "browserTarget": "ng-tetris:build" 77 | } 78 | } 79 | } 80 | } 81 | }, 82 | "defaultProject": "ng-tetris", 83 | "cli": { 84 | "analytics": false 85 | } 86 | } -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/app/piece.component.ts: -------------------------------------------------------------------------------- 1 | import { COLORS, SHAPES, COLORSLIGHTER, COLORSDARKER } from './constants'; 2 | 3 | export interface IPiece { 4 | x: number; 5 | y: number; 6 | color: string; 7 | shape: number[][]; 8 | } 9 | 10 | export class Piece implements IPiece { 11 | x: number; 12 | y: number; 13 | color: string; 14 | colorLighter: string; 15 | colorDarker: string; 16 | shape: number[][]; 17 | 18 | constructor(private ctx: CanvasRenderingContext2D) { 19 | this.spawn(); 20 | } 21 | 22 | spawn() { 23 | const typeId = this.randomizeTetrominoType(COLORS.length - 1); 24 | this.shape = SHAPES[typeId]; 25 | this.color = COLORS[typeId]; 26 | this.colorLighter = COLORSLIGHTER[typeId]; 27 | this.colorDarker = COLORSDARKER[typeId]; 28 | this.x = typeId === 4 ? 4 : 3; 29 | this.y = 0; 30 | } 31 | 32 | private add3D(ctx: CanvasRenderingContext2D, x: number, y: number): void { 33 | //Darker Color 34 | ctx.fillStyle = this.colorDarker; 35 | // Vertical 36 | ctx.fillRect(x + .9, y, .1, 1); 37 | // Horizontal 38 | ctx.fillRect(x, y + .9, 1, .1); 39 | 40 | //Darker Color - Inner 41 | // Vertical 42 | ctx.fillRect(x + .65, y + .3, .05, .3); 43 | // Horizontal 44 | ctx.fillRect(x + .3, y + .6, .4, .05); 45 | 46 | // Lighter Color - Outer 47 | ctx.fillStyle = this.colorLighter; 48 | 49 | // Lighter Color - Inner 50 | // Vertical 51 | ctx.fillRect(x + .3, y + .3, .05, .3); 52 | // Horizontal 53 | ctx.fillRect(x + .3, y + .3, .4, .05); 54 | 55 | // Lighter Color - Outer 56 | // Vertical 57 | ctx.fillRect(x, y, .05, 1); 58 | ctx.fillRect(x, y, .1, .95); 59 | // Horizontal 60 | ctx.fillRect(x, y, 1 , .05); 61 | ctx.fillRect(x, y, .95, .1); 62 | } 63 | 64 | private addNextShadow(ctx: CanvasRenderingContext2D, x: number, y: number): void { 65 | ctx.fillStyle = 'black'; 66 | ctx.fillRect(x, y, 1.025, 1.025); 67 | } 68 | 69 | draw() { 70 | this.shape.forEach((row, y) => { 71 | row.forEach((value, x) => { 72 | if (value > 0) { 73 | this.ctx.fillStyle = this.color; 74 | const currentX = this.x + x; 75 | const currentY = this.y + y; 76 | this.ctx.fillRect(currentX, currentY, 1, 1); 77 | this.add3D(this.ctx, currentX, currentY); 78 | } 79 | }); 80 | }); 81 | } 82 | 83 | drawNext(ctxNext: CanvasRenderingContext2D) { 84 | ctxNext.clearRect(0, 0, ctxNext.canvas.width, ctxNext.canvas.height); 85 | this.shape.forEach((row, y) => { 86 | row.forEach((value, x) => { 87 | if (value > 0) { 88 | this.addNextShadow(ctxNext, x, y); 89 | } 90 | }); 91 | }); 92 | 93 | ctxNext.fillStyle = this.color; 94 | this.shape.forEach((row, y) => { 95 | row.forEach((value, x) => { 96 | if (value > 0) { 97 | ctxNext.fillStyle = this.color; 98 | const currentX = x + .025; 99 | const currentY = y + .025; 100 | ctxNext.fillRect(currentX, currentY, 1-.025, 1 -.025); 101 | this.add3D(ctxNext, currentX, currentY); 102 | } 103 | }); 104 | }); 105 | } 106 | 107 | move(p: IPiece) { 108 | this.x = p.x; 109 | this.y = p.y; 110 | this.shape = p.shape; 111 | } 112 | 113 | randomizeTetrominoType(noOfTypes: number): number { 114 | return Math.floor(Math.random() * noOfTypes + 1); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/app/board.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ViewChild, 4 | ElementRef, 5 | OnInit, 6 | HostListener 7 | } from '@angular/core'; 8 | import { 9 | COLS, 10 | BLOCK_SIZE, 11 | ROWS, 12 | COLORS, 13 | COLORSLIGHTER, 14 | LINES_PER_LEVEL, 15 | LEVEL, 16 | POINTS, 17 | KEY, 18 | COLORSDARKER 19 | } from './constants'; 20 | import { Piece, IPiece } from './piece.component'; 21 | import { GameService } from './game.service'; 22 | import { Zoundfx } from 'ng-zzfx'; 23 | 24 | @Component({ 25 | selector: 'game-board', 26 | templateUrl: 'board.component.html' 27 | }) 28 | export class BoardComponent implements OnInit { 29 | @ViewChild('board', { static: true }) 30 | canvas: ElementRef; 31 | @ViewChild('next', { static: true }) 32 | canvasNext: ElementRef; 33 | ctx: CanvasRenderingContext2D; 34 | ctxNext: CanvasRenderingContext2D; 35 | board: number[][]; 36 | piece: Piece; 37 | next: Piece; 38 | requestId: number; 39 | paused: boolean; 40 | gameStarted: boolean; 41 | time: { start: number; elapsed: number; level: number }; 42 | points: number; 43 | highScore: number; 44 | lines: number; 45 | level: number; 46 | moves = { 47 | [KEY.LEFT]: (p: IPiece): IPiece => ({ ...p, x: p.x - 1 }), 48 | [KEY.RIGHT]: (p: IPiece): IPiece => ({ ...p, x: p.x + 1 }), 49 | [KEY.DOWN]: (p: IPiece): IPiece => ({ ...p, y: p.y + 1 }), 50 | [KEY.SPACE]: (p: IPiece): IPiece => ({ ...p, y: p.y + 1 }), 51 | [KEY.UP]: (p: IPiece): IPiece => this.service.rotate(p) 52 | }; 53 | playSoundFn: Function; 54 | 55 | @HostListener('window:keydown', ['$event']) 56 | keyEvent(event: KeyboardEvent) { 57 | if (event.keyCode === KEY.ESC) { 58 | this.gameOver(); 59 | } else if (this.moves[event.keyCode]) { 60 | event.preventDefault(); 61 | // Get new state 62 | let p = this.moves[event.keyCode](this.piece); 63 | if (event.keyCode === KEY.SPACE) { 64 | // Hard drop 65 | while (this.service.valid(p, this.board)) { 66 | this.points += POINTS.HARD_DROP; 67 | this.piece.move(p); 68 | p = this.moves[KEY.DOWN](this.piece); 69 | } 70 | } else if (this.service.valid(p, this.board)) { 71 | this.piece.move(p); 72 | if (event.keyCode === KEY.DOWN) { 73 | this.points += POINTS.SOFT_DROP; 74 | } 75 | } 76 | } 77 | } 78 | 79 | constructor(private service: GameService) {} 80 | 81 | ngOnInit() { 82 | this.initBoard(); 83 | this.initSound(); 84 | this.initNext(); 85 | this.resetGame(); 86 | this.highScore = 0; 87 | } 88 | 89 | initSound() { 90 | this.playSoundFn = Zoundfx.start(0.2); 91 | } 92 | 93 | initBoard() { 94 | this.ctx = this.canvas.nativeElement.getContext('2d'); 95 | 96 | // Calculate size of canvas from constants. 97 | this.ctx.canvas.width = COLS * BLOCK_SIZE; 98 | this.ctx.canvas.height = ROWS * BLOCK_SIZE; 99 | 100 | // Scale so we don't need to give size on every draw. 101 | this.ctx.scale(BLOCK_SIZE, BLOCK_SIZE); 102 | } 103 | 104 | initNext() { 105 | this.ctxNext = this.canvasNext.nativeElement.getContext('2d'); 106 | 107 | // Calculate size of canvas from constants. 108 | // The + 2 is to allow for space to add the drop shadow to 109 | // the "next piece" 110 | this.ctxNext.canvas.width = 4 * BLOCK_SIZE + 2; 111 | this.ctxNext.canvas.height = 4 * BLOCK_SIZE; 112 | 113 | this.ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE); 114 | } 115 | 116 | play() { 117 | this.gameStarted = true; 118 | this.resetGame(); 119 | this.next = new Piece(this.ctx); 120 | this.piece = new Piece(this.ctx); 121 | this.next.drawNext(this.ctxNext); 122 | this.time.start = performance.now(); 123 | 124 | // If we have an old game running a game then cancel the old 125 | if (this.requestId) { 126 | cancelAnimationFrame(this.requestId); 127 | } 128 | 129 | this.animate(); 130 | } 131 | 132 | resetGame() { 133 | this.points = 0; 134 | this.lines = 0; 135 | this.level = 0; 136 | this.board = this.getEmptyBoard(); 137 | this.time = { start: 0, elapsed: 0, level: LEVEL[this.level] }; 138 | this.paused = false; 139 | this.addOutlines(); 140 | } 141 | 142 | animate(now = 0) { 143 | this.time.elapsed = now - this.time.start; 144 | if (this.time.elapsed > this.time.level) { 145 | this.time.start = now; 146 | if (!this.drop()) { 147 | this.gameOver(); 148 | return; 149 | } 150 | } 151 | this.draw(); 152 | this.requestId = requestAnimationFrame(this.animate.bind(this)); 153 | } 154 | 155 | draw() { 156 | this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); 157 | this.piece.draw(); 158 | this.drawBoard(); 159 | } 160 | 161 | drop(): boolean { 162 | let p = this.moves[KEY.DOWN](this.piece); 163 | if (this.service.valid(p, this.board)) { 164 | this.piece.move(p); 165 | } else { 166 | this.freeze(); 167 | this.clearLines(); 168 | if (this.piece.y === 0) { 169 | // Game over 170 | return false; 171 | } 172 | this.playSoundFn([ , , 224,.02,.02,.08,1,1.7,-13.9 , , , , , ,6.7]); 173 | this.piece = this.next; 174 | this.next = new Piece(this.ctx); 175 | this.next.drawNext(this.ctxNext); 176 | } 177 | return true; 178 | } 179 | 180 | clearLines() { 181 | let lines = 0; 182 | this.board.forEach((row, y) => { 183 | if (row.every(value => value !== 0)) { 184 | lines++; 185 | this.board.splice(y, 1); 186 | this.board.unshift(Array(COLS).fill(0)); 187 | } 188 | }); 189 | if (lines > 0) { 190 | this.points += this.service.getLinesClearedPoints(lines, this.level); 191 | this.lines += lines; 192 | if (this.lines >= LINES_PER_LEVEL) { 193 | this.level++; 194 | this.lines -= LINES_PER_LEVEL; 195 | this.time.level = LEVEL[this.level]; 196 | } 197 | } 198 | } 199 | 200 | freeze() { 201 | this.piece.shape.forEach((row, y) => { 202 | row.forEach((value, x) => { 203 | if (value > 0) { 204 | this.board[y + this.piece.y][x + this.piece.x] = value; 205 | } 206 | }); 207 | }); 208 | } 209 | 210 | private add3D(x: number, y: number, color: number): void { 211 | //Darker Color 212 | this.ctx.fillStyle = COLORSDARKER[color]; 213 | // Vertical 214 | this.ctx.fillRect(x + .9, y, .1, 1); 215 | // Horizontal 216 | this.ctx.fillRect(x, y + .9, 1, .1); 217 | 218 | //Darker Color - Inner 219 | // Vertical 220 | this.ctx.fillRect(x + .65, y + .3, .05, .3); 221 | // Horizontal 222 | this.ctx.fillRect(x + .3, y + .6, .4, .05); 223 | 224 | // Lighter Color - Outer 225 | this.ctx.fillStyle = COLORSLIGHTER[color]; 226 | 227 | // Lighter Color - Inner 228 | // Vertical 229 | this.ctx.fillRect(x + .3, y + .3, .05, .3); 230 | // Horizontal 231 | this.ctx.fillRect(x + .3, y + .3, .4, .05); 232 | 233 | // Lighter Color - Outer 234 | // Vertical 235 | this.ctx.fillRect(x, y, .05, 1); 236 | this.ctx.fillRect(x, y, .1, .95); 237 | // Horizontal 238 | this.ctx.fillRect(x, y, 1 , .05); 239 | this.ctx.fillRect(x, y, .95, .1); 240 | } 241 | 242 | private addOutlines() { 243 | for(let index = 1; index < COLS; index++) { 244 | this.ctx.fillStyle = 'black'; 245 | this.ctx.fillRect(index, 0, .025, this.ctx.canvas.height); 246 | } 247 | 248 | for(let index = 1; index < ROWS; index++) { 249 | this.ctx.fillStyle = 'black'; 250 | this.ctx.fillRect(0, index, this.ctx.canvas.width, .025); 251 | } 252 | } 253 | 254 | drawBoard() { 255 | this.board.forEach((row, y) => { 256 | row.forEach((value, x) => { 257 | if (value > 0) { 258 | this.ctx.fillStyle = COLORS[value]; 259 | this.ctx.fillRect(x, y, 1, 1); 260 | this.add3D(x, y, value); 261 | } 262 | }); 263 | }); 264 | this.addOutlines(); 265 | } 266 | 267 | pause() { 268 | if (this.gameStarted) { 269 | if (this.paused) { 270 | this.animate(); 271 | } else { 272 | this.ctx.font = '1px Arial'; 273 | this.ctx.fillStyle = 'black'; 274 | this.ctx.fillText('GAME PAUSED', 1.4, 4); 275 | cancelAnimationFrame(this.requestId); 276 | } 277 | 278 | this.paused = !this.paused; 279 | } 280 | } 281 | 282 | gameOver() { 283 | this.gameStarted = false; 284 | cancelAnimationFrame(this.requestId); 285 | this.highScore = this.points > this.highScore ? this.points : this.highScore; 286 | this.ctx.fillStyle = 'black'; 287 | this.ctx.fillRect(1, 3, 8, 1.2); 288 | this.ctx.font = '1px Arial'; 289 | this.ctx.fillStyle = 'red'; 290 | this.ctx.fillText('GAME OVER', 1.8, 4); 291 | } 292 | 293 | getEmptyBoard(): number[][] { 294 | return Array.from({ length: ROWS }, () => Array(COLS).fill(0)); 295 | } 296 | } 297 | --------------------------------------------------------------------------------