├── snapshot.png ├── README.md ├── index.html ├── .github └── workflows │ └── static.yml ├── .gitignore └── tetris.js /snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/setimouse/js-tetris/HEAD/snapshot.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | JS-Tetris 2 | =========== 3 | A Tetris game written by JavaScript. 4 | 5 | * Clone this project and open index.html in your browser. 6 | * Or you can [play the game](https://setimouse.github.io/js-tetris/) immediately. 7 | * Follow me: https://x.com/pekingmuge 8 | 9 | ![snapshot](./snapshot.png) 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 26 | 27 | 28 |
29 | 30 |
31 |
32 |

Keyboard

33 |

ArrowLeft: Left

34 |

ArrowRight: Right

35 |

ArrowDown: Down

36 |

ArrowUp: Rotate

37 |

Space: Drop

38 |

R: Reset

39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | # Upload entire repository 40 | path: '.' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /tetris.js: -------------------------------------------------------------------------------- 1 | let canvas = document.getElementById('game'); 2 | let ctx = canvas.getContext('2d'); 3 | 4 | // canvas size 5 | let { width: c_width, height: c_height } = canvas.getBoundingClientRect(); 6 | const COL = 10; 7 | const b_size = c_width / COL; // block size 8 | const line_count = Math.floor(c_height / b_size); // line count 9 | let gameOver = false; 10 | 11 | let space = new Array(line_count).fill(0); 12 | const tetrominos = [ 13 | [0b11, 0b11], // O 14 | [0b111, 0b010, 0b000], // T 15 | [0b011, 0b110, 0b000], // S 16 | [0b110, 0b011, 0b000], // Z 17 | [0b010, 0b010, 0b011], // L 18 | [0b010, 0b010, 0b110], // J 19 | [0b0000, 0b1111, 0b0000, 0b0000], // I 20 | ]; 21 | 22 | // check collided 23 | function collided(mino) { 24 | for (let i = 0; i < space.length; i++) { 25 | if ((space[i] & mino[i]) > 0) { 26 | return true; 27 | } 28 | } 29 | return false; 30 | } 31 | 32 | // stick tetromino into space 33 | function stick(tetromino) { 34 | const full_line = (1 << COL) - 1 35 | for (let i = 0; i < space.length; i++) { 36 | space[i] |= tetromino[i]; 37 | if (space[i] === full_line) { 38 | space[i] = 0; 39 | } 40 | } 41 | const r = space.filter(e => e > 0); 42 | space = [...new Array(line_count - r.length), ...r]; 43 | t = newTetromino(); 44 | } 45 | 46 | class Tetromino { 47 | constructor(t) { 48 | this.x = Math.floor(COL / 2) + 1; 49 | this.y = 0; 50 | this.tetromino = [...t]; 51 | this._put(); 52 | } 53 | 54 | _put() { 55 | this.mino = [ 56 | ...new Array(this.y).fill(0), 57 | ...this.tetromino.map(e => e << (COL - this.x)), 58 | ...new Array(line_count - this.tetromino.length - this.y).fill(0) 59 | ] 60 | } 61 | 62 | _canLeft() { 63 | if (this.mino.filter(e => e & (0b1 << (COL - 1))).length > 0) { 64 | return false; 65 | } 66 | const mino = this.mino.map(e => e << 1); 67 | if (collided(mino)) { 68 | return false; 69 | } 70 | return mino; 71 | } 72 | 73 | _canRight() { 74 | if (this.mino.filter(e => e & 1).length > 0) { 75 | return false; 76 | } 77 | const mino = this.mino.map(e => e >> 1); 78 | if (collided(mino)) { 79 | return false; 80 | } 81 | return mino; 82 | } 83 | 84 | _canDown() { 85 | if (this.mino[line_count - 1] > 0) { 86 | return false; 87 | } 88 | const mino = [0, ...this.mino.slice(0, line_count - 1)]; 89 | if (collided(mino)) { 90 | return false; 91 | } 92 | return mino; 93 | } 94 | 95 | moveLeft() { 96 | const mino = this._canLeft(); 97 | if (mino === false || collided(mino)) { 98 | return this; 99 | } 100 | this.mino = mino; 101 | this.x--; 102 | return this; 103 | } 104 | 105 | moveRight() { 106 | const mino = this._canRight(); 107 | if (mino === false || collided(mino)) { 108 | return this; 109 | } 110 | this.mino = mino; 111 | this.x = Math.min(this.x + 1, COL); 112 | return this; 113 | } 114 | 115 | down() { 116 | const mino = this._canDown() 117 | if (false === mino) { 118 | gameOver = this.y === 0; 119 | stick(this.mino) 120 | return this; 121 | } 122 | this.mino = mino; 123 | this.y++; 124 | return this; 125 | } 126 | 127 | drop() { 128 | while (this._canDown()) { 129 | this.down(); 130 | } 131 | t.down(); 132 | } 133 | 134 | _rotateTetromino() { 135 | const size = this.tetromino.length; 136 | let t = Array(size).fill(0); 137 | for (let i = 0; i < size; i++) { 138 | const e = this.tetromino[i]; 139 | for (let j = 0; j < size; j++) { 140 | const b = (e >> (size - j - 1)) & 1; 141 | t[j] = (b << i) | t[j]; 142 | } 143 | } 144 | while (t[0] === 0) { 145 | t = [...t.slice(1, t.length), 0] 146 | } 147 | console.log(t, this.x); 148 | // 小细节 149 | if (this.x < COL / 2) { 150 | while (t.reduce((a, b) => a | b) >> (size - 1) === 0) { 151 | console.log(t); 152 | t = t.map(e => e << 1); 153 | } 154 | } 155 | return t; 156 | } 157 | 158 | _canRotate() { 159 | const rotated = this._rotateTetromino(); 160 | const size = rotated.length; 161 | if (size < this.x) return rotated; 162 | const mino = [ 163 | ...new Array(this.y).fill(0), 164 | ...rotated.map(e => e << (COL - size)), 165 | ...new Array(line_count - this.y - size).fill(0), 166 | ]; 167 | if (collided(mino)) { 168 | return false; 169 | } 170 | this.x = size; 171 | return rotated; 172 | } 173 | 174 | rotate() { 175 | const rotated = this._canRotate(); 176 | if (false === rotated) { 177 | return this; 178 | } 179 | this.tetromino = [...rotated]; 180 | this._put(); 181 | return this; 182 | } 183 | } 184 | 185 | function newTetromino() { 186 | const rand = Math.floor(Math.random() * tetrominos.length); 187 | return new Tetromino(tetrominos[rand]); 188 | } 189 | 190 | let t = newTetromino(); 191 | 192 | function render() { 193 | renderSpace(); 194 | renderTetromino(t); 195 | if (gameOver) { 196 | renderGameover(); 197 | } 198 | requestAnimationFrame(render); 199 | } 200 | 201 | const bgColor = 'rgb(158, 173, 134)'; 202 | const bgBlock = 'rgba(0,0,0,.854)'; 203 | const fgBlock = 'rgba(0,0,0,.09)'; 204 | 205 | render(); 206 | 207 | function renderGameover() { 208 | ctx.save(); 209 | ctx.font = "40px Arial bold italic"; 210 | ctx.textBaseline = "bottom"; 211 | ctx.textAlign = 'center'; 212 | ctx.fillText("Game Over", c_width / 2, c_height / 2); 213 | ctx.strokeStyle = "#fff"; 214 | ctx.strokeText("Game Over", c_width / 2, c_height / 2); 215 | ctx.restore(); 216 | } 217 | 218 | function renderBox(x, y) { 219 | const padding = 2, innerPadding = 5; 220 | ctx.strokeRect(x * b_size + padding, y * b_size + padding, b_size - padding * 2, b_size - padding * 2); 221 | ctx.fillRect(x * b_size + innerPadding, y * b_size + innerPadding, b_size - innerPadding * 2, b_size - innerPadding * 2); 222 | } 223 | 224 | function renderSpace() { 225 | ctx.fillStyle = bgColor; 226 | ctx.fillRect(0, 0, c_width, c_height); 227 | for (let y = 0; y < space.length; y++) { 228 | for (let x = 0; x < COL; x++) { 229 | const b = space[y] >> (COL - x - 1) & 1 > 0 230 | ctx.save(); 231 | ctx.fillStyle = b? bgBlock : fgBlock; 232 | ctx.strokeStyle = b ? bgBlock : fgBlock; 233 | renderBox(x, y); 234 | ctx.restore(); 235 | } 236 | } 237 | } 238 | 239 | function renderTetromino(tetromino) { 240 | ctx.save(); 241 | ctx.strokeStyle = bgBlock; 242 | ctx.fillStyle = bgBlock; 243 | for (let y = 0; y < tetromino.mino.length; y++) { 244 | for (let x = 0; x < COL; x++) { 245 | if ((1 << x) & tetromino.mino[y]) 246 | renderBox(COL - x - 1, y); 247 | } 248 | } 249 | ctx.restore(); 250 | } 251 | 252 | window.addEventListener('keydown', (e) => { 253 | switch (e.key) { 254 | case 'r': 255 | case 'R': 256 | reset(); 257 | break; 258 | } 259 | if (gameOver) return; 260 | switch (e.key) { 261 | case 'ArrowDown': 262 | t.down(); 263 | break; 264 | case 'ArrowLeft': 265 | t.moveLeft(); 266 | break; 267 | case 'ArrowRight': 268 | t.moveRight(); 269 | break; 270 | case 'ArrowUp': 271 | t.rotate(); 272 | break; 273 | case ' ': 274 | t.drop(); 275 | break; 276 | } 277 | }); 278 | 279 | function tick() { 280 | if (!gameOver) { 281 | t.down(); 282 | } 283 | } 284 | 285 | setInterval(tick, 1000); 286 | 287 | function reset() { 288 | gameOver = false; 289 | space = new Array(line_count).fill(0); 290 | t = newTetromino(); 291 | } 292 | 293 | reset(); --------------------------------------------------------------------------------