diff --git a/apps/2048/README.md b/apps/2048/README.md new file mode 100644 index 0000000..222e94a --- /dev/null +++ b/apps/2048/README.md @@ -0,0 +1,29 @@ +# 2048 + +Slide tiles with the direction buttons; equal tiles merge and the score grows. A new 2 (or +occasionally 4) appears after every move that changes the board. The game ends when the grid is full +with no equal neighbours. + +## Controls + +| Button | Action | +|---|---| +| Up / Down / Left / Right | Slide the board | +| Confirm | New game | +| Back | Exit | + +## Notes + +- The best score is kept in `/.apps/2048/best.txt` and written on game over, new game, and exit. +- Tile shade tracks magnitude (white → light gray → dark gray → black), which is the one place the + panel renders true grayscale via `gui.fillRoundedRect`. +- Every move repaints the whole grid with `REFRESH_FAST`; the panel diffs frames, so only changed + tiles actually move. + +## Testing + +Logic runs off-device with stubbed firmware globals: + +```bash +nix run nixpkgs#lua -- _scratch/test_2048.lua +``` diff --git a/apps/2048/main.lua b/apps/2048/main.lua new file mode 100644 index 0000000..2d25742 --- /dev/null +++ b/apps/2048/main.lua @@ -0,0 +1,229 @@ +-- DESCRIPTION: Slide and merge tiles to reach 2048 + +local SIZE = 4 +local CELLS = SIZE * SIZE +local BEST_PATH = "/.apps/2048/best.txt" + +local board = {} +local score = 0 +local best = 0 +local message = "" +local won = false +local gameOver = false +local firstDraw = true +local needsDraw = true + +local function loadBest() + local text = fs.readFile(BEST_PATH) + best = tonumber(text and text:match("%d+") or "") or 0 +end + +local function saveBest() + if score <= best then return end + best = score + fs.writeFile(BEST_PATH, tostring(best)) +end + +local function spawnTile() + local empty = {} + for index = 1, CELLS do + if board[index] == 0 then empty[#empty + 1] = index end + end + if #empty == 0 then return end + board[empty[math.random(#empty)]] = math.random(10) == 1 and 4 or 2 +end + +local function startGame() + for index = 1, CELLS do board[index] = 0 end + score = 0 + won = false + gameOver = false + message = "Slide to merge" + firstDraw = true + needsDraw = true + spawnTile() + spawnTile() +end + +-- Line Indices - Every move is the same left-slide over a row or column read in the right order. +local function lineIndices(direction, line) + local vertical = direction == "up" or direction == "down" + local reversed = direction == "right" or direction == "down" + + local indices = {} + for step = 1, SIZE do + local offset = reversed and SIZE + 1 - step or step + if vertical then + indices[step] = (offset - 1) * SIZE + line + else + indices[step] = (line - 1) * SIZE + offset + end + end + return indices +end + +local function slide(values) + local packed = {} + for _, value in ipairs(values) do + if value ~= 0 then packed[#packed + 1] = value end + end + + local out, gained, index = {}, 0, 1 + while index <= #packed do + if packed[index] == packed[index + 1] then + local merged = packed[index] * 2 + out[#out + 1] = merged + gained = gained + merged + index = index + 2 + else + out[#out + 1] = packed[index] + index = index + 1 + end + end + for position = #out + 1, SIZE do out[position] = 0 end + + return out, gained +end + +local function move(direction) + local moved = false + + for line = 1, SIZE do + local indices = lineIndices(direction, line) + local values = {} + for step = 1, SIZE do values[step] = board[indices[step]] end + + local slid, gained = slide(values) + score = score + gained + for step = 1, SIZE do + if board[indices[step]] ~= slid[step] then + board[indices[step]] = slid[step] + moved = true + end + end + end + + return moved +end + +local function hasMoves() + for index = 1, CELLS do + if board[index] == 0 then return true end + local column = (index - 1) % SIZE + 1 + if column < SIZE and board[index] == board[index + 1] then return true end + if index + SIZE <= CELLS and board[index] == board[index + SIZE] then return true end + end + return false +end + +local function step(direction) + if move(direction) then + spawnTile() + message = "Slide to merge" + + if not won then + for index = 1, CELLS do + if board[index] >= 2048 then + won = true + message = "2048! Keep going" + end + end + end + else + message = "No room that way" + end + + if not hasMoves() then + gameOver = true + message = "No moves - OK for a new game" + saveBest() + end + + needsDraw = true +end + +local function tileStyle(value) + if value >= 256 then return COLOR_BLACK, COLOR_WHITE end + if value >= 32 then return COLOR_DARK_GRAY, COLOR_WHITE end + if value >= 8 then return COLOR_LIGHT_GRAY, COLOR_BLACK end + return COLOR_WHITE, COLOR_BLACK +end + +local function drawTile(value, x, y, size) + local fill, textColor = tileStyle(value) + + if value == 0 then + gui.drawRoundedRect(x, y, size, size, 1, 6, COLOR_LIGHT_GRAY) + return + end + + gui.fillRoundedRect(x, y, size, size, 6, fill) + gui.drawRoundedRect(x, y, size, size, 2, 6, COLOR_BLACK) + + local label = tostring(value) + local font = FONT_NOTOSANS_16 + if gui.getTextWidth(font, label, STYLE_BOLD) > size - 10 then font = FONT_NOTOSANS_12 end + local textWidth = gui.getTextWidth(font, label, STYLE_BOLD) + gui.drawText(font, x + math.floor((size - textWidth) / 2), y + math.floor(size / 2) - 10, label, textColor, + STYLE_BOLD) +end + +local function render() + local width, height = gui.width(), gui.height() + local top = 86 + local gap = 8 + local size = math.min( + math.floor((width - 48 - gap * (SIZE - 1)) / SIZE), + math.floor((height - top - 80 - gap * (SIZE - 1)) / SIZE)) + local gridWidth = size * SIZE + gap * (SIZE - 1) + local startX = math.floor((width - gridWidth) / 2) + + gui.clear() + gui.drawCenteredText(FONT_NOTOSANS_16, 16, "2048", COLOR_BLACK, STYLE_BOLD) + gui.drawCenteredText(FONT_SMALL, 46, "Score " .. score .. " Best " .. best, COLOR_BLACK, STYLE_REGULAR) + gui.drawCenteredText(FONT_SMALL, 64, message, COLOR_BLACK, STYLE_REGULAR) + + for row = 1, SIZE do + for column = 1, SIZE do + drawTile(board[(row - 1) * SIZE + column], + startX + (column - 1) * (size + gap), + top + (row - 1) * (size + gap), size) + end + end + + gui.drawButtonHints("<<", "New", "<", ">") + gui.refresh(firstDraw and REFRESH_HALF or REFRESH_FAST) + firstDraw = false + needsDraw = false +end + +function init() + math.randomseed(sys.millis()) + loadBest() + startGame() +end + +function draw() + if input.wasPressed("back") then + saveBest() + sys.exit() + return + end + + if input.wasPressed("confirm") then + saveBest() + startGame() + elseif not gameOver then + if input.wasPressed("left") then + step("left") + elseif input.wasPressed("right") then + step("right") + elseif input.wasPressed("up") then + step("up") + elseif input.wasPressed("down") then + step("down") + end + end + + if needsDraw then render() end +end diff --git a/apps/2048/manifest.txt b/apps/2048/manifest.txt new file mode 100644 index 0000000..f1062b8 --- /dev/null +++ b/apps/2048/manifest.txt @@ -0,0 +1,2 @@ +XTEINK-MANIFEST|1 +main.lua|6324|da9bc8bbe2153eb8ba85e295dbe5e4ec9f4b0c8df57c2fc3eec5ed2dc185bba1 diff --git a/catalog.txt b/catalog.txt index e4619dc..a78fb67 100644 --- a/catalog.txt +++ b/catalog.txt @@ -1,4 +1,5 @@ XTEINK-CATALOG|1 +2048|Slide and merge tiles to reach 2048|apps/2048/manifest.txt|97|44ea59e862e09bb4ed034fc3fe9c280a13bb814bd6cc16670482bb3e34db0180 AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|2eb608b92de6bdeff4fc8ed447b9319f45c0d79d5d73b14db1ab5a92f91e42d4 BinaryKeyboard|Two-button recursive keyboard experiment|apps/BinaryKeyboard/manifest.txt|97|893867dba0ae2725caca1eb56375c98c4dd00d351134aa129698c51f97e25690 HomeAssistant|Home Assistant status cards|apps/HomeAssistant/manifest.txt|177|b38ca49804444fd383a505ce9c51680ad9b7443950d682ca1b9ef3047bff98a5