240 lines
7.1 KiB
Lua
240 lines
7.1 KiB
Lua
-- menus.lua — narrates focus in the game's menu screens.
|
|||
|
|
--
|
||
|
|
-- Most menu states keep their focus in file-local variables (`selectedOption`,
|
||
|
|
-- `optionsList`, ...) which are not reachable through the table the state returns.
|
||
|
|
-- They ARE reachable as upvalues of the state's own functions, via
|
||
|
|
-- debug.getupvalue.
|
||
|
|
--
|
||
|
|
-- Per docs/05-decisions.md (D3) this driver supports two interchangeable
|
||
|
|
-- backends, so we can move between them without rewriting per-screen logic:
|
||
|
|
--
|
||
|
|
-- mode "probe" -- read upvalues. Zero edits to game files. Depends on
|
||
|
|
-- internal variable names, so it is checked by the self-test.
|
||
|
|
-- mode "patched" -- game files call accessibility.announce() directly. Robust,
|
||
|
|
-- but must be re-applied on each game version.
|
||
|
|
--
|
||
|
|
-- The `render` function in each descriptor -- which holds all the per-screen
|
||
|
|
-- knowledge -- is shared by both. Switching modes is a config flag.
|
||
|
|
|
||
|
|
local speech = require("accessibility.speech")
|
||
|
|
local stateNames = require("accessibility.stateNames")
|
||
|
|
|
||
|
|
local menus = {}
|
||
|
|
|
||
|
|
menus.mode = "probe"
|
||
|
|
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
-- Upvalue access
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
|
||
|
|
local function getUpvalue(fn, name)
|
||
|
|
if type(fn) ~= "function" then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local i = 1
|
||
|
|
while true do
|
||
|
|
local n, v = debug.getupvalue(fn, i)
|
||
|
|
if not n then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
if n == name then
|
||
|
|
return v, i
|
||
|
|
end
|
||
|
|
i = i + 1
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
menus.getUpvalue = getUpvalue
|
||
|
|
|
||
|
|
-- IMPORTANT: states.lua loads states with require("states/"..name) -- a SLASH.
|
||
|
|
-- require() caches on the exact string, so require("states.main_menu") would load
|
||
|
|
-- a SECOND, independent copy of the file with its own upvalues, and we would be
|
||
|
|
-- reading focus from a state the player is not interacting with. Always match the
|
||
|
|
-- game's spelling.
|
||
|
|
local function getStateTable(stateName)
|
||
|
|
local ok, stateTable = pcall(require, "states/" .. stateName)
|
||
|
|
if not ok then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
return stateTable
|
||
|
|
end
|
||
|
|
|
||
|
|
menus.getStateTable = getStateTable
|
||
|
|
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
-- Descriptors
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
|
||
|
|
local descriptors = {}
|
||
|
|
|
||
|
|
local function loadDescriptor(stateName)
|
||
|
|
if descriptors[stateName] ~= nil then
|
||
|
|
return descriptors[stateName] or nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local ok, descriptor = pcall(require, "accessibility.states." .. stateName)
|
||
|
|
descriptors[stateName] = ok and descriptor or false
|
||
|
|
return descriptors[stateName] or nil
|
||
|
|
end
|
||
|
|
|
||
|
|
menus.loadDescriptor = loadDescriptor
|
||
|
|
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
-- Reading focus
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
|
||
|
|
-- Returns: text to speak, focus key (for change detection).
|
||
|
|
local function readFocus(stateName)
|
||
|
|
local descriptor = loadDescriptor(stateName)
|
||
|
|
if not descriptor then
|
||
|
|
return nil, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local stateTable = getStateTable(stateName)
|
||
|
|
if not stateTable then
|
||
|
|
return nil, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
-- A descriptor may take full control, for screens that aren't a simple list
|
||
|
|
-- (text entry, tabbed pages).
|
||
|
|
if descriptor.read then
|
||
|
|
local ok, text, key = pcall(descriptor.read, stateTable, menus)
|
||
|
|
if ok then
|
||
|
|
return text, key
|
||
|
|
end
|
||
|
|
return nil, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local probe = descriptor.probe
|
||
|
|
if not probe then
|
||
|
|
return nil, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local probeFn = stateTable[probe.fn or "keypressed"]
|
||
|
|
local index = getUpvalue(probeFn, probe.focus)
|
||
|
|
local list = getUpvalue(probeFn, probe.list)
|
||
|
|
|
||
|
|
if type(index) ~= "number" or type(list) ~= "table" then
|
||
|
|
return nil, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local entry = list[index]
|
||
|
|
if entry == nil then
|
||
|
|
return nil, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local total = #list
|
||
|
|
local ok, text = pcall(descriptor.render, entry, index, total)
|
||
|
|
if not ok or not text then
|
||
|
|
return nil, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
return text, tostring(index) .. "/" .. tostring(total)
|
||
|
|
end
|
||
|
|
|
||
|
|
menus.readFocus = readFocus
|
||
|
|
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
-- Announcing
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
|
||
|
|
local lastKey
|
||
|
|
local lastState
|
||
|
|
local justSwitched = false
|
||
|
|
|
||
|
|
-- Called after a state switch: announce the screen name only.
|
||
|
|
--
|
||
|
|
-- Focus is deliberately left to the poll in the update hook rather than read
|
||
|
|
-- here. A state's open() may set focus after the switch returns, and
|
||
|
|
-- mousemoved can override it on the very first frame -- so reading now would
|
||
|
|
-- often announce the wrong entry. The poll picks it up a frame later, and
|
||
|
|
-- `justSwitched` makes that first announcement non-interrupting so it appends
|
||
|
|
-- to the screen name ("Main menu. Continue, 1 of 7") instead of cutting it off.
|
||
|
|
function menus.announceScreen(stateName)
|
||
|
|
lastState = stateName
|
||
|
|
lastKey = nil
|
||
|
|
justSwitched = true
|
||
|
|
|
||
|
|
speech.say(stateNames[stateName] or stateName, true)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Called after a keypress: announce the focused entry if it changed.
|
||
|
|
function menus.announceFocusIfChanged(stateName)
|
||
|
|
if stateName ~= lastState then
|
||
|
|
lastState = stateName
|
||
|
|
lastKey = nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local text, key = readFocus(stateName)
|
||
|
|
if not text then
|
||
|
|
return
|
||
|
|
end
|
||
|
|
|
||
|
|
if key ~= nil and key == lastKey then
|
||
|
|
return
|
||
|
|
end
|
||
|
|
|
||
|
|
lastKey = key
|
||
|
|
|
||
|
|
-- First announcement after a switch appends to the screen name; later ones
|
||
|
|
-- interrupt, so holding an arrow key doesn't queue a backlog of options.
|
||
|
|
speech.say(text, not justSwitched)
|
||
|
|
justSwitched = false
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Force a re-read, e.g. for a "where am I" key.
|
||
|
|
function menus.repeatFocus(stateName)
|
||
|
|
local text = readFocus(stateName)
|
||
|
|
if text then
|
||
|
|
speech.say(text, true)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
function menus.resetFocusTracking()
|
||
|
|
lastKey = nil
|
||
|
|
end
|
||
|
|
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
-- Self test — the version-bump canary
|
||
|
|
--------------------------------------------------------------------------------
|
||
|
|
|
||
|
|
-- Reports, for every descriptor we ship, whether its probe still resolves against
|
||
|
|
-- the current game code. A renamed local shows up here at startup instead of as
|
||
|
|
-- silence three screens into a playthrough.
|
||
|
|
function menus.selfTest(stateList)
|
||
|
|
local results = {}
|
||
|
|
|
||
|
|
for _, stateName in ipairs(stateList) do
|
||
|
|
local descriptor = loadDescriptor(stateName)
|
||
|
|
if not descriptor then
|
||
|
|
results[stateName] = "no descriptor"
|
||
|
|
elseif descriptor.read then
|
||
|
|
results[stateName] = "custom read()"
|
||
|
|
else
|
||
|
|
local stateTable = getStateTable(stateName)
|
||
|
|
if not stateTable then
|
||
|
|
results[stateName] = "STATE NOT LOADABLE"
|
||
|
|
else
|
||
|
|
local probeFn = stateTable[descriptor.probe.fn or "keypressed"]
|
||
|
|
local index = getUpvalue(probeFn, descriptor.probe.focus)
|
||
|
|
local list = getUpvalue(probeFn, descriptor.probe.list)
|
||
|
|
|
||
|
|
-- index is often nil until the screen is opened; the list existing
|
||
|
|
-- is the real signal that the probe still points at something.
|
||
|
|
if list == nil then
|
||
|
|
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.list) .. "'"
|
||
|
|
elseif index == nil and not descriptor.probe.focusMayBeNil then
|
||
|
|
results[stateName] = "probe partial: '" .. tostring(descriptor.probe.focus) .. "' nil (may be normal before open)"
|
||
|
|
else
|
||
|
|
results[stateName] = "ok"
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
return results
|
||
|
|
end
|
||
|
|
|
||
|
|
return menus
|