Pulls menu focus narration forward from Phase 4: it was a blocker, since the screen name was spoken but not the selected option, so the game could not be started at all. Game-file footprint unchanged: still one line in main.lua. - menus.lua: descriptor driver supporting both D3 backends (upvalue probe and patched-announce), sharing one render per screen. Probe self-test included. - Descriptors for main_menu, settings_menu, load_game, new_game, narration. - testbed.lua: drives the game by code via love.event.push, so input takes the same path as a human keypress. Coroutine-based, and navigates by READING focus rather than counting keypresses, which drifts when options are disabled. Three bugs found by the testbed and fixed: 1. love.keypressed was still bound to the pre-wrapper states.keypressed. states.setup() captures love[callback] by value and runs before us, and main.lua does not define love.keypressed itself, so wrapping the states table field alone had no effect. Now reassigns love.keypressed/textinput. 2. Focus can move with no keypress: main_menu.mousemoved assigns selectedOption from cursor position, silently overriding selection including at startup. Focus is now polled in the update hook instead of announced only on keypress. 3. Probes sometimes need chaining. narration.keypressed only calls continueNarration(), so the text locals are upvalues of that inner function. Verified: main menu -> new game -> name entry -> intro narration -> gameplay, 8/8 checks passing, including console.print capture and its (x2) fold. Known gap: ingame_dialogue announces 'Game' and reads no dialogue text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
|