-- Tetris Game using GameEngine --- region dependencies local vector2 = require("vector2") local button = require("button") --- endregion --- region Constants local GRID_WIDTH = 10 local GRID_HEIGHT = 19 local BLOCK_SIZE = 30 local FALL_SPEED = 0.5 local screenWidth = 800 local screenHeight = 600 --- endregion --- region Tetrominoes local tetrominoes = { { shape = {{1, 1, 1, 1}}, color = Color.new(0, 255, 255) }, -- I { shape = {{1, 1}, {1, 1}}, color = Color.new(255, 255, 0) }, -- O { shape = {{0, 1, 0}, {1, 1, 1}}, color = Color.new(128, 0, 128) }, -- T { shape = {{1, 1, 0}, {0, 1, 1}}, color = Color.new(0, 255, 0) }, -- S { shape = {{0, 1, 1}, {1, 1, 0}}, color = Color.new(255, 0, 0) }, -- Z { shape = {{1, 1, 1}, {1, 0, 0}}, color = Color.new(255, 165, 0) }, -- L { shape = {{1, 1, 1}, {0, 0, 1}}, color = Color.new(0, 0, 255) } -- J } local wallKicks = { -- These values come from the official Super Rotation System (SRS) -- https://harddrop.com/wiki/SRS I = { [0] = { {0, 0}, {-2, 0}, {1, 0}, {-2, -1}, {1, 2} }, [1] = { {0, 0}, {2, 0}, {-1, 0}, {2, 1}, {-1, -2} }, [2] = { {0, 0}, {-1, 0}, {2, 0}, {-1, 2}, {2, -1} }, [3] = { {0, 0}, {1, 0}, {-2, 0}, {1, -2}, {-2, 1} } }, Default = { [0] = { {0, 0}, {-1, 0}, {-1, 1}, {0, -2}, {-1, -2} }, [1] = { {0, 0}, {1, 0}, {1, -1}, {0, 2}, {1, 2} }, [2] = { {0, 0}, {1, 0}, {1, 1}, {0, -2}, {1, -2} }, [3] = { {0, 0}, {-1, 0}, {-1, -1}, {0, 2}, {-1, 2} } } } --- endregion -- Game State local grid = {} local currentPiece = {} local pieceX, pieceY = 4, 0 local fallTimer = 0 local lastKeyState = { Left = false, Right = false, Down = false, Rotate = false, Space = true, Shift = false } local hasGottenLeaderBoard = false local leaderboard = {} local netError = false local nextPiece = {} local pieceBag = {} local heldPiece = nil local canHold = true -- enum of gameState, Main Menu, Playing, Submitting Name, Game Over local MAIN_MENU = 0 local PLAYING = 1 local GAME_OVER = 2 local SUBMITTING_NAME = 3 local gameState = MAIN_MENU local combo = 0 local backToBackTetris = false local nameTextBox local score = 0 -- list of bitmap frames that should be animated local leaderboardFrames = {} local leaderboardFrameTimer = 0 local leaderboardFrameIndex = 1 local leaderboardFreamSpeed = 0.05 local FALL_SPEED = 0.5 local linesClearedTotal = 0 local level = 1 --- region Bitmaps local titleScreenBitmap local gameOverBitmap local pressRBitmap local enterNameBitmap local boardBitmap local controlsBitmap --- endregion local yesButton = button.new(350, 400, 100, 50, "Yes", Color.new(0, 255, 0), function() print("Sending Post") local playerName = nameTextBox:GetText() if playerName == "" then playerName = "Anonymous" end local data = "{ \"player\": \"" .. playerName .. "\", \"score\": " .. score .. " }" local response = GameEngine:postRequest("https://api.brammie15.dev/leaderboard/tetris", data) if(response == "Error: Request timed out") then netError = true end print(response) gameState = GAME_OVER end) local noButton = button.new(350, 500, 100, 50, "No", Color.new(255, 0, 0), function() gameState = GAME_OVER end) --- region BagStuff local function shuffleBag() pieceBag = {} local indices = {1, 2, 3, 4, 5, 6, 7} for i = #indices, 2, -1 do local j = math.random(i) indices[i], indices[j] = indices[j], indices[i] end for _, index in ipairs(indices) do table.insert(pieceBag, tetrominoes[index]) end end local function getPieceType() if #currentPiece.shape == 4 then return "I" elseif #currentPiece.shape == 2 then return "O" else return "Default" end end local function getNextPiece() if #pieceBag == 0 then shuffleBag() end local pieceData = table.remove(pieceBag, 1) return { shape = pieceData.shape, color = pieceData.color } end local function getRandomPiece() local pieceData = tetrominoes[math.random(#tetrominoes)] return { shape = pieceData.shape, color = pieceData.color } end --- endregion local function checkCollision(px, py, piece) for y = 1, #piece.shape do for x = 1, #piece.shape[y] do if piece.shape[y][x] == 1 then if px + x < 1 or px + x > GRID_WIDTH or py + y > GRID_HEIGHT or (grid[py + y] and grid[py + y][px + x].value ~= 0) then return true end end end end return false end local function newPiece() currentPiece = nextPiece or getNextPiece() nextPiece = getNextPiece() pieceX, pieceY = 4, 0 if checkCollision(pieceX, pieceY, currentPiece) then gameState = SUBMITTING_NAME end end --- region GhostPieces local function getGhostPieceY() local ghostY = pieceY while not checkCollision(pieceX, ghostY + 1, currentPiece) do ghostY = ghostY + 1 end return ghostY end local function drawGhostPiece() local ghostY = getGhostPieceY() GameEngine:setColor(Color.new(200, 200, 200)) -- Semi-transparent gray for y = 1, #currentPiece.shape do for x = 1, #currentPiece.shape[y] do if currentPiece.shape[y][x] == 1 then GameEngine:fillRect((pieceX + x) * BLOCK_SIZE, (ghostY + y) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) end end end end --- endregion local function drawNextPiece() GameEngine:setColor(Color.new(255, 255, 255)) GameEngine:drawText("Next:", 650, 100) if nextPiece then for y = 1, #nextPiece.shape do for x = 1, #nextPiece.shape[y] do if nextPiece.shape[y][x] == 1 then GameEngine:setColor(nextPiece.color) GameEngine:fillRect(650 + x * BLOCK_SIZE, 120 + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) end end end end end local function clearLines() local linesCleared = 0 local newGrid = {} for y = 1, GRID_HEIGHT do local full = true for x = 1, GRID_WIDTH do if grid[y][x].value == 0 then full = false break end end if not full then table.insert(newGrid, grid[y]) else linesCleared = linesCleared + 1 end end while #newGrid < GRID_HEIGHT do local emptyRow = {} for x = 1, GRID_WIDTH do emptyRow[x] = { value = 0, color = Color.new(255, 255, 255) } end table.insert(newGrid, 1, emptyRow) end grid = newGrid -- Update total lines cleared and level if linesCleared > 0 then linesClearedTotal = linesClearedTotal + linesCleared level = math.floor(linesClearedTotal / 10) + 1 -- Level up every 10 lines FALL_SPEED = math.max(0.1, 0.5 - (level * 0.05)) -- Speed up, but never below 0.1 end end local function freezePiece() for y = 1, #currentPiece.shape do for x = 1, #currentPiece.shape[y] do if currentPiece.shape[y][x] == 1 then local gridY = pieceY + y local gridX = pieceX + x -- Ensure it's within bounds before assigning if gridY >= 1 and gridY <= GRID_HEIGHT and gridX >= 1 and gridX <= GRID_WIDTH then grid[gridY][gridX] = { value = 1, color = currentPiece.color } end end end end clearLines() newPiece() canHold = true end local function rotatePiece() local newShape = {} for x = 1, #currentPiece.shape[1] do newShape[x] = {} for y = 1, #currentPiece.shape do newShape[x][#currentPiece.shape - y + 1] = currentPiece.shape[y][x] end end local pieceType = getPieceType() if pieceType == "O" then currentPiece.shape = newShape return end local rotationIndex = (currentPiece.rotation or 0) % 4 local kickTable = wallKicks[pieceType][rotationIndex] for _, offset in ipairs(kickTable) do local newX, newY = pieceX + offset[1], pieceY + offset[2] if not checkCollision(newX, newY, { shape = newShape }) then currentPiece.shape = newShape pieceX, pieceY = newX, newY currentPiece.rotation = (rotationIndex + 1) % 4 return end end end local function drawGrid() for y = 1, GRID_HEIGHT do for x = 1, GRID_WIDTH do if grid[y][x].value ~= 0 then GameEngine:setColor(grid[y][x].color) GameEngine:fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) end GameEngine:setColor(Color.new(50, 50, 50)) GameEngine:drawRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) end end GameEngine:setColor(Color.new(255, 255, 255)) GameEngine:drawRect(BLOCK_SIZE, BLOCK_SIZE, GRID_WIDTH * BLOCK_SIZE, GRID_HEIGHT * BLOCK_SIZE) end local function drawPiece() GameEngine:setColor(currentPiece.color) for y = 1, #currentPiece.shape do for x = 1, #currentPiece.shape[y] do if currentPiece.shape[y][x] == 1 then GameEngine:fillRect((pieceX + x) * BLOCK_SIZE, (pieceY + y) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) end end end end local function drawHeldPiece() GameEngine:setColor(Color.new(255, 255, 255)) GameEngine:drawText("Hold:", 650, 300) if heldPiece then for y = 1, #heldPiece.shape do for x = 1, #heldPiece.shape[y] do if heldPiece.shape[y][x] == 1 then GameEngine:setColor(heldPiece.color) GameEngine:fillRect(650 + x * BLOCK_SIZE, 320 + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) end end end end end function setup_window() GameEngine:setTitle("Tetris") GameEngine:setWidth(screenWidth) GameEngine:setHeight(screenHeight) GameEngine:setFrameRate(60) end --- the set_keylist function --- @return string function set_keylist() return "WASD " end function start() -- Initialize grid for y = 1, GRID_HEIGHT do grid[y] = {} for x = 1, GRID_WIDTH do grid[y][x] = { value = 0, color = Color.new(255, 255, 255) } end end -- Don't blame me for logging :p local name = GameEngine:getName() local data = "{ \"name\": \"" .. name .. "\" }" GameEngine:getRequest("https://api.brammie15.dev/game-open", data) nextPiece = getRandomPiece() newPiece() nameTextBox = Textbox.new("") -- Center on screen local width = 175 local height = 25 nameTextBox:SetBounds(math.floor((screenWidth - width) / 2) - 10, 300, width, height) nameTextBox:Hide() titleScreenBitmap = Bitmap.new("resources/tetrisLogo.bmp", true) -- load frame0 - 4 for i = 0, 4 do local bmp = Bitmap.new("resources/leaderboard/frame" .. i .. ".bmp", true) bmp:SetTransparencyColor(Color.new(255, 0, 255)) table.insert(leaderboardFrames, bmp) end gameOverBitmap = Bitmap.new("resources/leaderboard/game_over.bmp", true) gameOverBitmap:SetTransparencyColor(Color.new(255, 0, 255)) pressRBitmap = Bitmap.new("resources/leaderboard/press_r.bmp", true) pressRBitmap:SetTransparencyColor(Color.new(255, 0, 255)) enterNameBitmap = Bitmap.new("resources/leaderboard/enter_name.bmp", true) enterNameBitmap:SetTransparencyColor(Color.new(255, 0, 255)) boardBitmap = Bitmap.new("resources/board.bmp", true) boardBitmap:SetTransparencyColor(Color.new(255, 0, 255)) controlsBitmap = Bitmap.new("resources/controls.bmp", true) controlsBitmap:SetTransparencyColor(Color.new(255, 0, 255)) end function update() -- print(GameEngine:getMouseX(), GameEngine:getMouseY()) if GameEngine:isKeyDown("ESCAPE") then GameEngine:quit() end if gameState == PLAYING then fallTimer = fallTimer + 1 / 60 if fallTimer >= FALL_SPEED then if not checkCollision(pieceX, pieceY + 1, currentPiece) then pieceY = pieceY + 1 else freezePiece() end fallTimer = 0 end local leftPressed = GameEngine:isKeyDown("A") or GameEngine:isKeyDown("LEFT") local rightPressed = GameEngine:isKeyDown("D") or GameEngine:isKeyDown("RIGHT") local downPressed = GameEngine:isKeyDown("S") or GameEngine:isKeyDown("DOWN") local rotatePressed = GameEngine:isKeyDown("W") or GameEngine:isKeyDown("UP") local spacePressed = GameEngine:isKeyDown(" ") local shiftPressed = GameEngine:isKeyDown("SHIFT") if leftPressed and not lastKeyState.Left and not checkCollision(pieceX - 1, pieceY, currentPiece) then pieceX = pieceX - 1 end if rightPressed and not lastKeyState.Right and not checkCollision(pieceX + 1, pieceY, currentPiece) then pieceX = pieceX + 1 end if downPressed and not lastKeyState.Down and not checkCollision(pieceX, pieceY + 1, currentPiece) then pieceY = pieceY + 1 end if shiftPressed and not lastKeyState.Shift and canHold then if heldPiece then -- Swap current piece with held piece currentPiece, heldPiece = heldPiece, currentPiece else heldPiece = currentPiece currentPiece = getNextPiece() end -- Reset position for the new piece pieceX, pieceY = 4, 0 canHold = false -- Prevent repeated swaps until the piece is placed end if spacePressed and not lastKeyState.Space then local dropDistance = getGhostPieceY() - pieceY score = score + (dropDistance * 2) -- 2 points per row pieceY = getGhostPieceY() freezePiece() end if rotatePressed and not lastKeyState.Rotate then rotatePiece() end lastKeyState.Left = leftPressed lastKeyState.Right = rightPressed lastKeyState.Down = downPressed lastKeyState.Rotate = rotatePressed lastKeyState.Space = spacePressed lastKeyState.Shift = shiftPressed end if gameState == GAME_OVER then --Update leaderboard gif leaderboardFrameTimer = leaderboardFrameTimer + 1 / 60 if leaderboardFrameTimer >= leaderboardFreamSpeed then leaderboardFrameIndex = leaderboardFrameIndex + 1 if leaderboardFrameIndex > #leaderboardFrames then leaderboardFrameIndex = 1 end leaderboardFrameTimer = 0 end if not hasGottenLeaderBoard then local response = GameEngine:getRequest("https://api.brammie15.dev/leaderboard/tetris", "") if response == "Error: Request timed out" then netError = true end print(response) -- format is -- NAME SCORE if not netError then for line in response:gmatch("[^\r\n]+") do local name, score = line:match("([^%s]+)%s+(%d+)") table.insert(leaderboard, { name = name, score = tonumber(score) }) end table.sort(leaderboard, function(a, b) return a.score > b.score end) end hasGottenLeaderBoard = true end if GameEngine:isKeyDown("R") then gameState = PLAYING score = 0 level = 1 netError = false hasGottenLeaderBoard = false leaderboard = {} nameTextBox:SetText("") grid = {} for y = 1, GRID_HEIGHT do grid[y] = {} for x = 1, GRID_WIDTH do grid[y][x] = { value = 0, color = Color.new(255, 255, 255) } end end newPiece() end end if gameState == SUBMITTING_NAME then yesButton:update(GameEngine) noButton:update(GameEngine) end if gameState == MAIN_MENU then if GameEngine:isKeyDown(" ") then gameState = PLAYING end end end function drawScoreBoard() GameEngine:setColor(Color.new(255, 0, 0)) GameEngine:drawText("Score: " .. score, 350, 400) if hasGottenLeaderBoard then local NamesX = 475 local ScoresX = 625 local Y = 150 GameEngine:setColor(Color.new(255, 255, 0)) -- GameEngine:drawText("Leaderboard", 600, 60) GameEngine:drawBitmap(leaderboardFrames[leaderboardFrameIndex], 450, 60) if netError then GameEngine:setColor(Color.new(255, 0, 0)) GameEngine:drawText("Error: Request timed out", 600, 100) GameEngine:drawText("Or server down", 600, 120) GameEngine:drawText("Not gonna check :p", 600, 140) return end for i = 1, math.min(#leaderboard, 20) do GameEngine:setColor(Color.new(255, 255, 255)) GameEngine:drawText(leaderboard[i].name, NamesX, Y) GameEngine:drawText(leaderboard[i].score, ScoresX, Y) Y = Y + 20 end else GameEngine:setColor(Color.new(255, 255, 255)) GameEngine:drawText("Loading Leaderboard...", 600, 100) end end function drawGame() GameEngine:fillScreen(Color.new(0, 0, 0)) drawGrid() GameEngine:drawBitmap(boardBitmap, 0, 0) drawGhostPiece() drawPiece() drawHeldPiece() drawNextPiece() -- Draw the next piece preview GameEngine:setColor(Color.new(255, 255, 255)) GameEngine:drawText("Score: " .. score, 650, 50) GameEngine:drawText("Level: " .. level, 650, 75) end function drawGameOver() nameTextBox:Hide() GameEngine:fillScreen(Color.new(0, 0, 0)) GameEngine:drawBitmap(gameOverBitmap, 0, 0) GameEngine:drawBitmap(pressRBitmap, 0, 0) drawScoreBoard() end function drawSubmitName() GameEngine:fillScreen(Color.new(0, 0, 0)) GameEngine:drawBitmap(enterNameBitmap, 0, 0) nameTextBox:Show() yesButton:draw(GameEngine) noButton:draw(GameEngine) end function drawMainMenu() GameEngine:drawBitmap(titleScreenBitmap, 0, 0) GameEngine:drawBitmap(controlsBitmap, 0, 0) end function draw() if gameState == PLAYING then drawGame() end if gameState == GAME_OVER then drawGameOver() end if gameState == SUBMITTING_NAME then drawSubmitName() end if gameState == MAIN_MENU then drawMainMenu() end end function quit() print("bye") local name = GameEngine:getName() local data = "{ \"name\": \"" .. name .. "\" }" GameEngine:getRequest("https://api.brammie15.dev/game-close", data) end