├── 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 | 
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();
--------------------------------------------------------------------------------