180 lines
5.8 KiB
Lua
180 lines
5.8 KiB
Lua
-- 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,
|
||
|
|
}
|