Files

180 lines
5.8 KiB
Lua
Raw Permalink Normal View History

-- Descriptor for `ingame_popup` — the modal popup shown when you learn a recipe,
-- read a lore fragment, unfold a map, or loot from a menu.
--
-- WHY THIS EXISTS
--
-- `patches/popupstack.lua` covers the *fading corner* popups, and it looked like
-- that was all of them. It isn't. `views/fadingPopup.lua` registers only four
-- types (journal / loot / bestiary / tutorial); recipe, lore and map popups never
-- touch popupStack at all. They are built directly by `modules/{recipe,lore,map,
-- loot}Popup.lua` and shown by `states.switch("ingame_popup", {popup = ...})`
-- from `states/{ingame,inventory,research}.lua`.
--
-- So `ingame_popup` had a spoken name in stateNames ("Popup") and no descriptor:
-- the mod announced the word "Popup" and then went silent over the entire body
-- of text. That is exactly the reported symptom.
--
-- HOW THE TEXT IS REACHED
--
-- Each popup module is a factory returning a table whose only member is `draw`
-- (recipe/lore/map) — every string is a closure local. So we take `popupObject`
-- off the state and read the strings as upvalues of `popupObject.draw`. That is
-- the same probe technique the menu descriptors use. Because it probes the popup
-- object rather than the state, the startup self-test can only report
-- "custom read()" here — the version-bump canary is the testbed check, which
-- builds each popup type and asserts its text still comes out.
--
-- Blocks are pushed into the console history so `[` / `]` / `\` walk a long lore
-- fragment back afterwards — a popup can easily be several paragraphs, which is
-- too much to catch in one utterance.
local history = require("accessibility.history")
-- Layout newlines are pure presentation: `lorePopup`'s footer is one sentence
-- with hard breaks in it, and a reader pauses mid-thought at every one. Same
-- flattening states/journal.lua does to journal pages.
local function flatten(text)
if type(text) ~= "string" then
return nil
end
text = text:gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", "")
if text == "" then
return nil
end
return text
end
local function add(blocks, text)
text = flatten(text)
if text then
blocks[#blocks + 1] = text
end
end
-- Name a list entry. `recipePopup` builds {icon, title}; `lootPopup` builds real
-- items, which carry `name` and may stack.
local function entryName(entry)
if type(entry) ~= "table" then
return nil
end
local name = entry.title or entry.name
if type(name) ~= "string" then
return nil
end
local quantity = tonumber(entry.quantity)
if quantity and quantity > 1 then
return name .. ", " .. quantity
end
return name
end
-- The four popup modules differ only in which locals they hold and in what order
-- draw() paints them. Reading order follows draw order, so what is spoken matches
-- what is on screen top to bottom.
--
-- Detection is by which locals exist, since the popup object itself carries no
-- type tag:
-- itemsList -> loot (real items)
-- items -> recipe ({title} rows)
-- title + subHeader -> lore
-- title -> map
local function shape(get)
if get("itemsList") ~= nil then
return "loot", { "flavourText", "header", "@itemsList" }
end
if get("items") ~= nil then
return "recipe", { "header", "subHeader", "@items", "footer" }
end
if get("title") ~= nil then
if get("subHeader") ~= nil then
return "lore", { "text", "header", "title", "subHeader", "footer" }
end
return "map", { "text", "title", "footer" }
end
-- Unknown popup type (a future game version adding one): read whatever of the
-- usual fields it has rather than falling back to silence.
return "unknown", { "text", "header", "subHeader", "title", "footer" }
end
local function blocksFor(stateTable, menus)
local popupObject = menus.getAnyUpvalue(stateTable, "popupObject")
if type(popupObject) ~= "table" or type(popupObject.draw) ~= "function" then
return nil, nil
end
local function get(name)
return menus.getUpvalue(popupObject.draw, name)
end
local _, fields = shape(get)
local blocks = {}
for _, field in ipairs(fields) do
if field:sub(1, 1) == "@" then
local list = get(field:sub(2))
if type(list) == "table" then
for _, entry in ipairs(list) do
add(blocks, entryName(entry))
end
end
else
add(blocks, get(field))
end
end
if #blocks == 0 then
return nil, nil
end
-- The popup table is unique per popup, so its identity is a sound focus key:
-- it changes exactly when a new popup is shown and never within one.
return blocks, tostring(popupObject)
end
-- Blocks are separate lines on screen, so they need a sentence break between them
-- when spoken -- but most already end in one ("...very old book."), and a blind
-- "." join gives NVDA "book.. This", which it reads with an audible stumble.
local function join(blocks)
local out = blocks[1]
for i = 2, #blocks do
local sep = out:find("[%.%?!:,]$") and " " or ". "
out = out .. sep .. blocks[i]
end
return out
end
local lastKey
return {
reset = function()
lastKey = nil
end,
-- read() is called from the poll, from the post-keypress announce and from
-- menus.repeatFocus, so it must be idempotent (see the warning in
-- states/journal.lua). It returns the same text for the same key every time;
-- only the history push is guarded, since duplicating a popup's paragraphs in
-- the review buffer on every frame would swamp it.
read = function(stateTable, menus)
local blocks, key = blocksFor(stateTable, menus)
if not blocks then
return nil, nil
end
if key ~= lastKey then
lastKey = key
for _, block in ipairs(blocks) do
pcall(history.append, block, "text")
end
end
return join(blocks), key
end,
-- `=` re-reads the popup on demand, for when the utterance went by.
details = function(stateTable, menus)
local blocks = blocksFor(stateTable, menus)
return blocks
end,
}