Opening the journal and selecting an entry announced its title and stopped, which is an index into content the player could never reach. Page flipping was silent too. The screen is three widgets at once: the tab row (q/e), the list of entry titles down the left (up/down), and the page on the right holding the actual text (left/right). The descriptor read only the first two. It now reads options[selectedOption].pages[selectedPageId] and speaks its text blocks. A page is bounded by what fits on screen, so it is safe as a single utterance. Layout newlines are flattened first -- splitTextByPages joins the game's own wrapped lines with \n, so left in they make the reader pause mid-sentence at every wrap point. Page position is announced only when there is more than one page, and the blocks are pushed into the console history so [ and ] walk back through a long lore entry after the utterance has gone by. The tab leads only when the tab changed and the title only when the entry changed, so flipping pages does not bury the new page under a repeated heading. That suppression made read() stateful, which is a trap: it is called for the same focus from four places -- the per-frame poll, the post-keypress announce, repeatFocus, and the testbed's before/after probes -- so a poll would consume the "changed" flag and the real announcement would come out headless. The descriptor now caches its last (key, text) and replays it whenever the key is unchanged, making read() idempotent; the suppression state advances exactly once per genuine focus change. Adds a generic descriptor.reset hook, called from menus.announceScreen. The memory lives in file-locals that outlive the screen, so without it reopening the journal on the entry you left it on would read the page with no title. Testbed asserts the spoken text contains the page body rather than merely being non-nil. 29 checks, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
5.4 KiB
Lua
161 lines
5.4 KiB
Lua
-- states/journal.lua — quests, lore, bestiary, components, recipes, tutorials.
|
|
--
|
|
-- Visually the screen is three things at once:
|
|
--
|
|
-- * a row of TABS across the top (Quests / Lore / Bestiary / Components /
|
|
-- Recipes / Tutorials), changed with tabLeft / tabRight;
|
|
-- * a scrolling LIST of entries down the left, each an icon plus a title,
|
|
-- changed with up / down;
|
|
-- * the selected entry's TEXT filling the right-hand page, paginated, with a
|
|
-- "< 2 / 3 >" counter under it and left / right flipping pages.
|
|
--
|
|
-- Only the first two were ever announced, so selecting an entry read its title
|
|
-- and then nothing — the page holding the actual content was silent, and page
|
|
-- flipping was silent too. That is the whole point of the screen.
|
|
--
|
|
-- The page is `options[selectedOption].pages[selectedPageId]`, a list of text
|
|
-- blocks (`{text = ..., color = ..., icon = ...}`). Its length is bounded by
|
|
-- what fits on screen, so a page is safe to speak in one go; it is also pushed
|
|
-- into the console history so [ and ] can walk it back, the same trick
|
|
-- _textscreen.lua uses for the manual.
|
|
--
|
|
-- Uses getAnyUpvalue because activeTabIndex, options, selectedOption and
|
|
-- selectedPageId are touched by different functions here (gotoTab,
|
|
-- prepareOptions, selectPage, keypressed).
|
|
|
|
local history = require("accessibility.history")
|
|
|
|
local lastTab
|
|
local lastEntry
|
|
local lastPushedPage
|
|
local lastKey
|
|
local lastText
|
|
|
|
-- The page splitter concatenates the game's own wrapped lines with "\n", so a
|
|
-- block's text carries hard newlines that are pure layout. Flatten them, or the
|
|
-- screen reader pauses mid-sentence at every wrap point.
|
|
local function flatten(text)
|
|
return (tostring(text):gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", ""))
|
|
end
|
|
|
|
local function pageBlocks(page)
|
|
local blocks = {}
|
|
|
|
for _, textBlock in ipairs(page) do
|
|
if type(textBlock) == "table" and type(textBlock.text) == "string" then
|
|
local text = flatten(textBlock.text)
|
|
if text ~= "" then
|
|
blocks[#blocks + 1] = text
|
|
end
|
|
end
|
|
end
|
|
|
|
return blocks
|
|
end
|
|
|
|
return {
|
|
-- Called by menus.announceScreen each time the journal is opened, so the tab
|
|
-- and title lead again rather than being suppressed as "unchanged" from the
|
|
-- last visit.
|
|
reset = function()
|
|
lastTab = nil
|
|
lastEntry = nil
|
|
lastPushedPage = nil
|
|
lastKey = nil
|
|
lastText = nil
|
|
end,
|
|
|
|
read = function(stateTable, menus)
|
|
local tabIndex = menus.getAnyUpvalue(stateTable, "activeTabIndex")
|
|
local tabsList = menus.getAnyUpvalue(stateTable, "tabsList")
|
|
local options = menus.getAnyUpvalue(stateTable, "options")
|
|
local selected = menus.getAnyUpvalue(stateTable, "selectedOption")
|
|
local pageId = menus.getAnyUpvalue(stateTable, "selectedPageId")
|
|
|
|
if type(tabIndex) ~= "number" or type(tabsList) ~= "table" then
|
|
return nil, nil
|
|
end
|
|
|
|
local optionCount = type(options) == "table" and #options or -1
|
|
local entry = type(options) == "table" and type(selected) == "number"
|
|
and options[selected] or nil
|
|
|
|
local key = table.concat({
|
|
tostring(tabIndex), tostring(selected), tostring(pageId), tostring(optionCount)
|
|
}, ":")
|
|
|
|
-- read() is called from several places for the same focus: the per-frame
|
|
-- poll, the post-keypress announce, the "repeat what's focused" key, and
|
|
-- the testbed's before/after probes. Only the FIRST read of a given focus
|
|
-- may advance the suppression state below -- otherwise a poll would
|
|
-- consume the "tab changed" flag and the announcement that follows would
|
|
-- come out without its heading. So later reads of the same key replay the
|
|
-- text verbatim, which also makes the repeat key say the full thing.
|
|
if key == lastKey then
|
|
return lastText, key
|
|
end
|
|
|
|
local tab = tabsList[tabIndex]
|
|
local tabName = tab and tab.header or ("tab " .. tabIndex)
|
|
|
|
local parts = {}
|
|
|
|
-- Lead with the tab only when it just changed, so stepping through a long
|
|
-- quest list doesn't repeat "Quests" on every line.
|
|
if tabIndex ~= lastTab then
|
|
parts[#parts + 1] = tabName
|
|
end
|
|
|
|
if optionCount == 0 then
|
|
parts[#parts + 1] = "empty"
|
|
elseif entry then
|
|
-- Repeating the title on every page flip would bury the page you just
|
|
-- turned to, so the title leads only when the entry itself changed.
|
|
local entryKey = tostring(tabIndex) .. ":" .. tostring(selected)
|
|
|
|
if entryKey ~= lastEntry then
|
|
parts[#parts + 1] = entry.title or entry.header or entry.name or "?"
|
|
parts[#parts + 1] = selected .. " of " .. optionCount
|
|
end
|
|
|
|
local pages = entry.pages
|
|
local page = type(pages) == "table" and type(pageId) == "number" and pages[pageId]
|
|
|
|
if page then
|
|
-- Only worth saying which page you are on when there is more than one.
|
|
if #pages > 1 then
|
|
parts[#parts + 1] = "page " .. pageId .. " of " .. #pages
|
|
end
|
|
|
|
local blocks = pageBlocks(page)
|
|
|
|
for _, block in ipairs(blocks) do
|
|
parts[#parts + 1] = block
|
|
end
|
|
|
|
if #blocks == 0 then
|
|
parts[#parts + 1] = "no text"
|
|
end
|
|
|
|
-- Mirror the page into the review buffer so [ and ] can walk it
|
|
-- block by block after the utterance has gone by.
|
|
local pageKey = entryKey .. ":" .. tostring(pageId)
|
|
if pageKey ~= lastPushedPage then
|
|
lastPushedPage = pageKey
|
|
for _, block in ipairs(blocks) do
|
|
pcall(history.append, block, "journal")
|
|
end
|
|
end
|
|
end
|
|
|
|
lastEntry = entryKey
|
|
end
|
|
|
|
lastTab = tabIndex
|
|
lastKey = key
|
|
lastText = #parts > 0 and table.concat(parts, ", ") or nil
|
|
|
|
return lastText, key
|
|
end,
|
|
}
|