39afd7fac7
The apps were on four-space indent only because nothing pinned a style; .editorconfig is what lua-language-server reads on save, so the tree now matches the sibling repos. Manifests and the catalog regenerate from the reformatted sources.
312 lines
8.5 KiB
Lua
312 lines
8.5 KiB
Lua
-- DESCRIPTION: Guess the five-letter word in six tries
|
|
|
|
local WORDS_PATH = "/.apps/Wordle/words.txt"
|
|
local WORD_LENGTH = 5
|
|
local MAX_GUESSES = 6
|
|
local SECTION_GAP = 10
|
|
|
|
local keyboard = {
|
|
{ "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P" },
|
|
{ "A", "S", "D", "F", "G", "H", "J", "K", "L" },
|
|
{ "OK", "Z", "X", "C", "V", "B", "N", "M", "DEL" },
|
|
}
|
|
|
|
local answer = nil
|
|
local current = ""
|
|
local guesses = {}
|
|
local results = {}
|
|
local keyStates = {}
|
|
local cursorRow = 1
|
|
local cursorColumn = 1
|
|
local message = ""
|
|
local gameOver = false
|
|
local firstDraw = true
|
|
|
|
-- Word Source - Replace only this function to fetch answers from another source.
|
|
local function getRandomWord()
|
|
local size = fs.fileSize(WORDS_PATH)
|
|
if not size then
|
|
return nil, "Missing " .. WORDS_PATH
|
|
end
|
|
if size == 0 then
|
|
return nil, "Word list is empty"
|
|
end
|
|
|
|
for _ = 1, 8 do
|
|
local word = fs.readLineAt(WORDS_PATH, math.random(0, size - 1)) or fs.readLineAt(WORDS_PATH, 0)
|
|
if word then
|
|
word = word:match "^%s*(.-)%s*$"
|
|
if #word == WORD_LENGTH and word:match "^%a+$" then
|
|
return word:upper()
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil, "Word list has no five-letter words"
|
|
end
|
|
|
|
local function startGame()
|
|
local errorMessage
|
|
answer, errorMessage = getRandomWord()
|
|
current = ""
|
|
guesses = {}
|
|
results = {}
|
|
keyStates = {}
|
|
cursorRow = 1
|
|
cursorColumn = 1
|
|
gameOver = not answer
|
|
message = errorMessage or "Pick a letter"
|
|
firstDraw = true
|
|
end
|
|
|
|
local function evaluateGuess(guess)
|
|
local status = {}
|
|
local remaining = {}
|
|
|
|
for index = 1, WORD_LENGTH do
|
|
local letter = answer:sub(index, index)
|
|
if guess:sub(index, index) == letter then
|
|
status[index] = 2
|
|
else
|
|
remaining[letter] = (remaining[letter] or 0) + 1
|
|
end
|
|
end
|
|
|
|
for index = 1, WORD_LENGTH do
|
|
if not status[index] then
|
|
local letter = guess:sub(index, index)
|
|
if (remaining[letter] or 0) > 0 then
|
|
status[index] = 1
|
|
remaining[letter] = remaining[letter] - 1
|
|
else
|
|
status[index] = 0
|
|
end
|
|
end
|
|
end
|
|
|
|
return status
|
|
end
|
|
|
|
local function submitGuess()
|
|
if #current ~= WORD_LENGTH then
|
|
message = "Enter five letters"
|
|
return
|
|
end
|
|
|
|
local status = evaluateGuess(current)
|
|
guesses[#guesses + 1] = current
|
|
results[#results + 1] = status
|
|
|
|
for index = 1, WORD_LENGTH do
|
|
local letter = current:sub(index, index)
|
|
keyStates[letter] = math.max(keyStates[letter] or 0, status[index])
|
|
end
|
|
|
|
if current == answer then
|
|
message = "Solved! Press OK for a new word"
|
|
gameOver = true
|
|
elseif #guesses == MAX_GUESSES then
|
|
message = answer .. " - Press OK for a new word"
|
|
gameOver = true
|
|
else
|
|
message = tostring(MAX_GUESSES - #guesses) .. " guesses left"
|
|
end
|
|
current = ""
|
|
end
|
|
|
|
local function useKey(key)
|
|
if key == "OK" then
|
|
submitGuess()
|
|
elseif key == "DEL" then
|
|
current = current:sub(1, -2)
|
|
message = "Pick a letter"
|
|
elseif #current < WORD_LENGTH then
|
|
current = current .. key
|
|
message = "Pick a letter"
|
|
end
|
|
end
|
|
|
|
local function centeredText(font, x, y, width, text, color, style)
|
|
local textWidth = gui.getTextWidth(font, text, style)
|
|
gui.drawText(font, x + math.floor((width - textWidth) / 2), y, text, color, style)
|
|
end
|
|
|
|
local function drawTile(letter, status, x, y, size, active)
|
|
local fill = COLOR_WHITE
|
|
local textColor = COLOR_BLACK
|
|
|
|
if status == 2 then
|
|
fill = COLOR_BLACK
|
|
textColor = COLOR_WHITE
|
|
elseif status == 1 then
|
|
fill = COLOR_LIGHT_GRAY
|
|
elseif status == 0 then
|
|
fill = COLOR_DARK_GRAY
|
|
textColor = COLOR_WHITE
|
|
end
|
|
|
|
if status ~= nil then
|
|
gui.fillRoundedRect(x, y, size, size, 5, fill)
|
|
end
|
|
gui.drawRoundedRect(x, y, size, size, active and 3 or 2, 5, COLOR_BLACK)
|
|
-- Present Marker - Gray shades alone are hard to tell apart on e-ink, so "in word, wrong spot" also gets an inset ring.
|
|
if status == 1 then
|
|
gui.drawRoundedRect(x + 5, y + 5, size - 10, size - 10, 2, 3, COLOR_BLACK)
|
|
end
|
|
if letter ~= "" then
|
|
if active then
|
|
gui.drawRoundedRect(x + 4, y + 4, size - 8, size - 8, 1, 3, COLOR_BLACK)
|
|
end
|
|
centeredText(FONT_NOTOSANS_16, x, y + math.floor(size / 2) - 10, size, letter, textColor, STYLE_BOLD)
|
|
end
|
|
end
|
|
|
|
local function keyboardMetrics(height)
|
|
local gap = 4
|
|
local keyHeight = math.min(52, math.floor(height * 0.07))
|
|
return height - 56 - (keyHeight * 3 + gap * 2), keyHeight
|
|
end
|
|
|
|
local function drawGrid(width, height)
|
|
local gap = 6
|
|
local startY = 72
|
|
local keyboardTop = keyboardMetrics(height)
|
|
local gridBottom = keyboardTop - SECTION_GAP * 2 - 18
|
|
local size = math.min(
|
|
72,
|
|
math.floor((width - 40 - gap * (WORD_LENGTH - 1)) / WORD_LENGTH),
|
|
math.floor((gridBottom - startY - gap * (MAX_GUESSES - 1)) / MAX_GUESSES)
|
|
)
|
|
local gridWidth = size * WORD_LENGTH + gap * (WORD_LENGTH - 1)
|
|
local startX = math.floor((width - gridWidth) / 2)
|
|
|
|
for row = 1, MAX_GUESSES do
|
|
local active = row == #guesses + 1
|
|
local word = guesses[row] or (active and current or "")
|
|
local status = results[row]
|
|
for column = 1, WORD_LENGTH do
|
|
drawTile(
|
|
word:sub(column, column),
|
|
status and status[column] or nil,
|
|
startX + (column - 1) * (size + gap),
|
|
startY + (row - 1) * (size + gap),
|
|
size,
|
|
active
|
|
)
|
|
end
|
|
end
|
|
|
|
return startY + MAX_GUESSES * size + (MAX_GUESSES - 1) * gap
|
|
end
|
|
|
|
local function drawLegend(width, y)
|
|
local items = {
|
|
{ "Right", 2 },
|
|
{ "Spot", 1 },
|
|
{ "Miss", 0 },
|
|
}
|
|
local swatch = 18
|
|
local gap = 12
|
|
local itemWidth = 76
|
|
local startX = math.floor((width - itemWidth * #items - gap * (#items - 1)) / 2)
|
|
|
|
for index, item in ipairs(items) do
|
|
local x = startX + (index - 1) * (itemWidth + gap)
|
|
drawTile("", item[2], x, y, swatch)
|
|
gui.drawText(FONT_SMALL, x + swatch + 5, y + 2, item[1], COLOR_BLACK, STYLE_REGULAR)
|
|
end
|
|
end
|
|
|
|
local function drawKeyboard(width, height)
|
|
local gap = 4
|
|
local margin = 12
|
|
local keyWidth = math.floor((width - margin * 2 - gap * 9) / 10)
|
|
local top, keyHeight = keyboardMetrics(height)
|
|
|
|
for rowIndex, row in ipairs(keyboard) do
|
|
local rowWidth = #row * keyWidth + (#row - 1) * gap
|
|
local startX = math.floor((width - rowWidth) / 2)
|
|
local y = top + (rowIndex - 1) * (keyHeight + gap)
|
|
|
|
for columnIndex, key in ipairs(row) do
|
|
local x = startX + (columnIndex - 1) * (keyWidth + gap)
|
|
local status = keyStates[key]
|
|
local fill = status == 2 and COLOR_BLACK
|
|
or status == 1 and COLOR_LIGHT_GRAY
|
|
or status == 0 and COLOR_DARK_GRAY
|
|
or COLOR_WHITE
|
|
local textColor = (status == 2 or status == 0) and COLOR_WHITE or COLOR_BLACK
|
|
|
|
gui.fillRoundedRect(x, y, keyWidth, keyHeight, 4, fill)
|
|
gui.drawRoundedRect(x, y, keyWidth, keyHeight, 1, 4, COLOR_BLACK)
|
|
if status == 1 then
|
|
gui.drawRoundedRect(x + 4, y + 4, keyWidth - 8, keyHeight - 8, 1, 3, COLOR_BLACK)
|
|
end
|
|
if rowIndex == cursorRow and columnIndex == cursorColumn then
|
|
gui.drawRoundedRect(x + 2, y + 2, keyWidth - 4, keyHeight - 4, 3, 3, textColor)
|
|
end
|
|
centeredText(FONT_SMALL, x, y + math.floor(keyHeight / 2) - 7, keyWidth, key, textColor, STYLE_BOLD)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function render()
|
|
local width, height = gui.width(), gui.height()
|
|
gui.clear()
|
|
gui.drawCenteredText(FONT_NOTOSANS_16, 8, "WORDLE", COLOR_BLACK, STYLE_BOLD)
|
|
gui.drawCenteredText(FONT_SMALL, 46, message, COLOR_BLACK, STYLE_REGULAR)
|
|
|
|
if answer then
|
|
local gridBottom = drawGrid(width, height)
|
|
drawLegend(width, gridBottom + SECTION_GAP)
|
|
drawKeyboard(width, height)
|
|
end
|
|
|
|
gui.drawButtonHints("<<", "Select", "<", ">")
|
|
gui.refresh(firstDraw and REFRESH_HALF or REFRESH_FAST)
|
|
firstDraw = false
|
|
end
|
|
|
|
function init()
|
|
startGame()
|
|
render()
|
|
end
|
|
|
|
function on_button(name, event)
|
|
if event ~= "down" then
|
|
return
|
|
end
|
|
|
|
if name == "back" then
|
|
sys.exit()
|
|
return
|
|
end
|
|
|
|
if gameOver and answer then
|
|
if name ~= "confirm" then
|
|
return
|
|
end
|
|
startGame()
|
|
elseif answer then
|
|
if name == "left" then
|
|
cursorColumn = cursorColumn == 1 and #keyboard[cursorRow] or cursorColumn - 1
|
|
elseif name == "right" then
|
|
cursorColumn = cursorColumn == #keyboard[cursorRow] and 1 or cursorColumn + 1
|
|
elseif name == "page_back" then
|
|
cursorRow = cursorRow == 1 and #keyboard or cursorRow - 1
|
|
cursorColumn = math.min(cursorColumn, #keyboard[cursorRow])
|
|
elseif name == "page_forward" then
|
|
cursorRow = cursorRow == #keyboard and 1 or cursorRow + 1
|
|
cursorColumn = math.min(cursorColumn, #keyboard[cursorRow])
|
|
elseif name == "confirm" then
|
|
useKey(keyboard[cursorRow][cursorColumn])
|
|
else
|
|
return
|
|
end
|
|
else
|
|
return
|
|
end
|
|
|
|
render()
|
|
end
|