diff --git a/content/js/tetris/tetris.js b/content/js/tetris/tetris.js index 08e8a22..790cbcc 100644 --- a/content/js/tetris/tetris.js +++ b/content/js/tetris/tetris.js @@ -13,10 +13,18 @@ const tetrominoes = [ [[1, 1, 1], [0, 1, 0]] // T ]; +// Define a function to get the next piece +function getNextTetromino() { + const tetromino = tetrominoes[Math.floor(Math.random() * tetrominoes.length)]; + return tetromino; +} + // Define the game board as a 2D array const board = Array.from({ length: boardHeight }, () => Array(boardWidth).fill(0)); // console.log("board:", board); +let nextTetromino = getNextTetromino(); + // Define the current tetromino and its position let currentTetromino = tetrominoes[Math.floor(Math.random() * tetrominoes.length)]; let currentX = boardWidth / 2 - currentTetromino[0].length / 2; @@ -33,44 +41,163 @@ const ctx = canvas.getContext('2d'); //const pauseButton = document.getElementById('pauseButton'); let isPaused = false; +let isGameOver = false; +let lines = 0; +let level = 1; +let score = 0; +let speed = 800; + +const levelSpeeds = [800, 600, 400, 300, 200, 100, 50]; + +// Define a function to calculate the level based on the score +function calculateLevel(score) { + return Math.floor(Math.log(score / 500 + 1) / Math.log(2) + 1); +} + +// Define a function to update the speed based on the level +function updateSpeed() { + level = calculateLevel(score); + speed = levelSpeeds[level - 1]; +} + +const fontGame = 18 + 'px Courier'; +const fontGameBorder = 18 + 'px Victor Mono'; +const fontGameInfo = 18 + 'px Victor Mono'; +const squareSize = 24; +const dotSize = 2; +const offsetX = (canvas.width / 2.0) - (boardWidth / 2.0 * squareSize) - squareSize / 2.0; +const offsetY = squareSize * 2; // Define a function to draw a square on the canvas -function drawSquare(x, y) { - // set square color to green - ctx.font = '18px courier'; - // set font to bold +function drawSquare(x, y, color='#05fa2a') { + const squareX = x * (squareSize - 5) + offsetX + 6; + const squareY = y * squareSize + offsetY - 3; + ctx.font = fontGame; ctx.fontWeight = 'bold'; - ctx.fillStyle = '#05fa2a'; - const squareSize = 20; - const offsetX = (canvas.width / 2.0 - (boardWidth * squareSize / 2.0) - (squareSize / 2.0)) + squareSize; + // set square color to green + ctx.fillStyle = color; // use fillText to draw square, text is "[]", make sure it's right bottom corner is at x, y - ctx.fillText('[]', x * 20 + 2 + offsetX, y * 20 + 18); + ctx.fillText('[]', squareX, squareY); } // Define a function to draw dot in the righ bottom of every square function drawDot(x, y) { - const squareSize = 20; - const dotSize = 2; - const offsetX = (canvas.width / 2.0 - (boardWidth * squareSize / 2.0) - (squareSize / 2.0)) + squareSize; - const squareX = x * squareSize + offsetX; - const squareY = y * squareSize; + const squareX = x * (squareSize - 5) + offsetX; + const squareY = y * squareSize + squareSize; const dotX = squareX + squareSize - dotSize; const dotY = squareY + squareSize - dotSize; - ctx.fillStyle = 'green'; + ctx.fillStyle = '#05fa2a'; ctx.fillRect(dotX, dotY, dotSize, dotSize); } +// Define a function to draw the border +function drawBorder() { + const leftBorderX = offsetX - squareSize/2 - 5; + const rightBorderX = leftBorderX + boardWidth * squareSize - squareSize; + const bottomBorderY = offsetY + boardHeight * squareSize; + const borderY = 0; + + ctx.font = fontGameBorder; + ctx.fillStyle = 'green'; + + // Left border + for (let i = 0; i < boardHeight; i++) { + ctx.fillText('', rightBorderX, borderY + i * squareSize + offsetY); + } + // Bottom border + for (let i = 0; i < boardWidth; i++) { + if (i === 0) { + ctx.fillText('', rightBorderX, bottomBorderY); + } + } + // Outer bottom border + for (let i = 0; i < boardWidth * 2 - 2; i++) { + if (i !== 0 && i !== 1) { + ctx.fillText('=', leftBorderX + i/2 * squareSize, bottomBorderY); + if (i % 2 === 1) { + ctx.fillText('/', leftBorderX + i/2 * squareSize, bottomBorderY + squareSize); + } else { + ctx.fillText('\\', leftBorderX + i/2 * squareSize, bottomBorderY + squareSize); + } + } + } +} + +// Define a function to pad string with spaces +function padString(left, right, length) { + if (left.length + right.length >= length) { + return left + right; // If the string is already longer or equal to the desired length, return the original string. + } + const numberOfSpacesToAdd = length - left.length - right.length; + const spaces = " ".repeat(numberOfSpacesToAdd); + const extendedStr= left + spaces + right; + + return extendedStr; +} + +// Define a function to draw the score +function drawGameInfo() { + ctx.font = fontGameInfo; + // set square color to green + ctx.fillStyle = '#05fa2a'; + const offsetX = 0; + const offsetY = squareSize * 2; + + ctx.fillText(padString('ПОЛНЫХ СТРОК: ', lines.toString(), 20), offsetX, offsetY); + ctx.fillText(padString('УРОВЕНЬ: ', level.toString(), 20), offsetX, offsetY + squareSize); + ctx.fillText('H: НАЛЕВО L: НАПРАВО', (canvas.width / 1.5) - squareSize / 2, offsetY + squareSize); + ctx.fillText(' K: ПОВОРОТ', (canvas.width / 1.5) - squareSize / 2, offsetY + squareSize * 2); + ctx.fillText(padString(' СЧЕТ: ', score.toString(), 12), offsetX, offsetY + squareSize * 2); + ctx.fillStyle = 'green'; + ctx.fillText('4:УСКОРИТЬ 5:СБРОСИТЬ', (canvas.width / 1.5) - squareSize / 2, offsetY + squareSize * 3); + ctx.fillText('1: ПОКАЗАТЬ СЛЕДУЮЩУЮ', (canvas.width / 1.5) - squareSize / 2, offsetY + squareSize * 4); + ctx.fillText('0: СТЕРЕТЬ ЭТОТ ТЕКСТ', (canvas.width / 1.5) - squareSize / 2, offsetY + squareSize * 5); + ctx.fillText(' ПРОБЕЛ - СБРОСИТЬ', (canvas.width / 1.5) - squareSize / 2, offsetY + squareSize * 6); +} + +function drawNextTetromino(nextTetromino) { + const nextTetrominoHeight = nextTetromino.length; + const nextTetrominoWidth = nextTetromino[0].length; + const offsetX = squareSize * 4; + const offsetY = squareSize * 12; + + for (let y = 0; y < nextTetrominoHeight; y++) { + for (let x = 0; x < nextTetrominoWidth; x++) { + if (nextTetromino[y][x]) { + let squareX = x * squareSize + offsetX; + let squareY = y * squareSize + offsetY; + ctx.font = fontGame; + ctx.fontWeight = 'bold'; + // set square color to green + ctx.fillStyle = '#05fa2a'; + // use fillText to draw square, text is "[]", make sure it's right bottom corner is at x, y + ctx.fillText('[]', squareX, squareY); + } + } + } +} + // Define a function to draw the game board -function drawBoard() { +function drawBoard(squareColor='#05fa2a') { + drawBorder(); + drawGameInfo(); for (let y = 0; y < boardHeight; y++) { for (let x = 0; x < boardWidth; x++) { drawDot(x, y); if (board[y][x]) { - drawSquare(x, y); + drawSquare(x, y, squareColor); } } } + drawNextTetromino(nextTetromino); } // Define a function to rotate the tetromino clockwise by 90 degrees @@ -123,18 +250,36 @@ function gameLoop() { }); // Remove the completed lines from the game board + let rowsCleared = 0; for (let y = boardHeight - 1; y >= 0; y--) { if (board[y].every(cell => cell)) { // Remove the completed line board.splice(y, 1); board.unshift(new Array(boardWidth).fill(0)); + rowsCleared++; + lines++; y++; } } + if (rowsCleared > 0) { + if (rowsCleared === 1) { + score += 40 * (level + 1); + } + if (rowsCleared === 2) { + score += 100 * (level + 1); + } + if (rowsCleared === 3) { + score += 300 * (level + 1); + } + if (rowsCleared === 4) { + score += 1200 * (level + 1); + } + } + updateSpeed(); // Choose a new tetromino - currentTetromino = tetrominoes[Math.floor(Math.random() * tetrominoes.length)]; - // currentTetromino = tetrominoes[1]; + currentTetromino = nextTetromino; + nextTetromino = getNextTetromino(); currentX = boardWidth / 2 - currentTetromino[0].length / 2; if (currentTetromino[0].length % 2 === 1) { currentX = boardWidth / 2 - (currentTetromino[0].length + 1) / 2; @@ -146,6 +291,10 @@ function gameLoop() { if (board[0].some(cell => cell)) { // End the game console.log('Game over!'); + isGameOver = true; + // Clear the canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawBoard(squareColor='green'); return; } @@ -168,7 +317,7 @@ function gameLoop() { // Wait for the next frame // requestAnimationFrame(gameLoop); - setTimeout(gameLoop, 400); + setTimeout(gameLoop, speed); } function render() { @@ -189,7 +338,7 @@ function render() { }); } -//// Add event listeners to the pause button +// Add event listeners to the pause button //pauseButton.addEventListener('click', () => { // isPaused = !isPaused; // pauseButton.textContent = isPaused ? 'Resume' : 'Pause'; @@ -197,48 +346,53 @@ function render() { // Add event listeners to the keyboard document.addEventListener('keydown', event => { - if (event.code === "ArrowLeft" || event.code === "KeyH") { // Left arrow - // Move the tetromino left - if (currentX > 0) { - currentX--; - render(); - // Check if the tetromino has collided with another tetromino - if (currentTetromino.some((row, y) => row.some((cell, x) => cell && board[currentY + y][currentX + x]))) { - // Move the tetromino back + if (!isPaused && !isGameOver) { + if (event.code === "ArrowLeft" || event.code === "KeyH") { // Left arrow + // Move the tetromino left + if (currentX > 0) { + currentX--; + render(); + // Check if the tetromino has collided with another tetromino + if (currentTetromino.some((row, y) => row.some((cell, x) => cell && board[currentY + y][currentX + x]))) { + // Move the tetromino back + currentX++; + render(); + } + } + } else if (event.code === "ArrowRight" || event.code === "KeyL") { // Right arrow + // Move the tetromino right + if (currentX + currentTetromino[0].length < 10) { currentX++; render(); + // Check if the tetromino has collided with another tetromino + if (currentTetromino.some((row, y) => row.some((cell, x) => cell && board[currentY + y][currentX + x]))) { + // Move the tetromino back + currentX--; + render(); + } } - } - } else if (event.code === "ArrowRight" || event.code === "KeyL") { // Right arrow - // Move the tetromino right - if (currentX + currentTetromino[0].length < 10) { - currentX++; + } else if (event.code === "ArrowDown" || event.code === "KeyJ") { // Down arrow + // Move the tetromino down + currentY++; render(); - // Check if the tetromino has collided with another tetromino - if (currentTetromino.some((row, y) => row.some((cell, x) => cell && board[currentY + y][currentX + x]))) { - // Move the tetromino back - currentX--; + // Check if the tetromino has collided with the bottom or another tetromino + if (currentY + currentTetromino.length > boardHeight || currentTetromino.some((row, y) => row.some((cell, x) => cell && board[currentY + y][currentX + x]))) { + // Move the tetromino back up + currentY--; + render(); + } else { + score++; + } + } else if (event.code === "ArrowUp" || event.code === "KeyK") { // Up arrow + // Rotate the tetromino clockwise + const rotatedTetromino = rotateTetromino(currentTetromino); + if (canMoveTo(currentX, currentY, rotatedTetromino)) { + currentTetromino = rotatedTetromino; render(); } } - } else if (event.code === "ArrowDown" || event.code === "KeyJ") { // Down arrow - // Move the tetromino down - currentY++; - render(); - // Check if the tetromino has collided with the bottom or another tetromino - if (currentY + currentTetromino.length > boardHeight || currentTetromino.some((row, y) => row.some((cell, x) => cell && board[currentY + y][currentX + x]))) { - // Move the tetromino back up - currentY--; - render(); - } - } else if (event.code === "ArrowUp" || event.code === "KeyK") { // Up arrow - // Rotate the tetromino clockwise - const rotatedTetromino = rotateTetromino(currentTetromino); - if (canMoveTo(currentX, currentY, rotatedTetromino)) { - currentTetromino = rotatedTetromino; - render(); - } - } else if (event.code === "Space") { // Space + } + if (event.code === "Space") { // Space // Pause or resume the game isPaused = !isPaused; pauseButton.textContent = isPaused ? 'Resume' : 'Pause'; diff --git a/content/md/posts/2023-05-25-tetris.md b/content/md/posts/2023-05-25-tetris.md index abb70a8..dc15371 100644 --- a/content/md/posts/2023-05-25-tetris.md +++ b/content/md/posts/2023-05-25-tetris.md @@ -2,14 +2,25 @@ :layout :post :tags ["tetris"] :toc false - :draft true + :draft false :slug "tetris" :hide false :js true - } + :external-css ["https://fonts.googleapis.com/css?family=Victor+Mono&display=swap"]} -我在电影[俄罗斯方块](http://movie.mtime.com/234329)中第一次知道 Tetris, 也就是俄罗斯方块的英文名, 是由 tetra 和 tennis 组成的, tetra 的意思是 4 (所有落下的方块都是由 4 个正方形组成的), 而 tennis, 也就是网球, 是俄罗斯方块的发明者, 阿列克谢·帕基特诺夫最喜欢的运动. +我在电影[《俄罗斯方块》](https://www.imdb.com/title/tt12758060/)中第一次知道 [Tetris](https://en.wikipedia.org/wiki/Tetris), 也就是俄罗斯方块的英文名, 是由 tetra 和 tennis 组成的, tetra 的意思是 4 (所有落下的方块都是由 4 个正方形组成的), 而 tennis, 也就是网球, 是俄罗斯方块的发明者, Alexey Pajitnov 最喜欢的运动. + +电影非常精彩, 剧情发展紧凑, 情节转折也很吸引人, 节奏如《社交网络》般流畅[^1]. + +## 复刻 Tetris + +电影中印象比较深刻的一幕是 Rogers 在 Pajitnov 家和他一起探讨[ Tetris 的功能](https://www.imdb.com/video/vi3500197657/?ref_=ext_shr_lnk) , 初版的俄罗斯方块看起来很复古很好看, 方块里的正方形甚至是一对中括号. + +刚好看到 GitHub 的 CEO 使用 GitHub Copilot X 在 18 分钟内[开发并部署了一个贪吃蛇游戏](https://github.blog/2023-05-05-web-summit-rio-2023-building-an-app-in-18-minutes-with-github-copilot-x/), 我也有了 Copilot Chat private beta 的使用资格, 于是决定在 Copilot 的帮助下复刻一个.:) +
- +
+ +[^1]: [没想到,《俄罗斯方块》还能拍得这么神!](http://content.mtime.com/article/229064178/)