Prompted by "do sighted players have a minimap we don't have access to?"
No widget, but two map surfaces were only half exposed: the world map
screen, and the level view itself, which IS the explored-floor map --
rememberedFoV keeps everything ever seen drawn, and a sighted player pans
over it and reads the fog edge at a glance.
The world map dropped the three things the picture carries: what kind of
place each area is (the icon shape), which way it lies, and how long
travelling there takes. The last is the number the decision turns on and
was only ever in the side panel. states/worldmap.lua is now a read() plus
details() rather than probe/render -- necessarily, because render() gets
no state table and the travel time needs the screen's currentAreaId, an
upvalue like options and selectedOption. Travel time is the game's own
getDistanceMessage, the same call the screen makes. The blurb runs to
several sentences, so it goes on = and the settle timer instead of the
focus line.
Two gotchas: options[i].icon is already an image object, so the icon id
string has to come off the model area; and describe.direction only tests
the sign of each delta, which on the map's floating-point coordinates
called almost everything diagonal, hence a 25% deadzone carrying the same
4/3 x-correction the game's own distance maths uses.
New frontier.lua answers "where haven't I been", which nothing did:
surroundings is local, examine is one tile, and the tracker only finds
things it already knows about. A frontier tile is seen, not blocking
movement, with an in-bounds unseen CARDINAL neighbour -- cardinal because
the diagonal test fires on the unseen tile behind every wall corner, so a
lit corridor would report an unexplored way per corner. Clustered
8-connected and reduced to each cluster's nearest member, so a wide
doorway is one way but two openings that both lie north stay two. Reads
only FoV/rememberedFoV and reports only THAT an opening exists, never
what is beyond it -- exactly what the fog edge shows.
Shift+End names the nearest exit without disturbing the tracker
selection. Note for later: level.exits does not exist at runtime, it is
packed as nil and never assigned. The primitive is the game's own
getListByTag("exit"), which already gates on FoV or rememberedFoV, skips
hidden tiles and sorts by distance. Exits were already a tracker
category, so this is a shortcut rather than a new capability.
"remembered" moves from verbose to normal verbosity. tileFacts reads
actors only when a tile is currently VISIBLE, so a remembered tile is
silent about whatever may be standing on it now, and at normal verbosity
that silence was indistinguishable from "nothing there". A safety flag,
not a nicety.
Also adds descriptor.probeNames: a read() descriptor has no probe table,
so menus.selfTest could only report "custom read()" and the version-bump
canary was lost on exactly the screens reaching for the most upvalues.
worldmap and research now declare their game locals and get the same
PROBE BROKEN check.
120 testbed checks, 0 failures. It caught two things review had not. The
world map said "You are currently here." twice, because
getDistanceMessage and getLastVisitedMessage return the same sentence for
the area you are standing in -- fine as two panels on screen, a stutter
in one utterance. And the first frontier check walked a step east and
demanded the numbers change; it failed on a run where nothing was wrong,
the step landing but both openings keeping the same Chebyshev distance.
Replaced with the deterministic property: every reported distance must
equal Chebyshev from the player's current position, nearest first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
11 KiB
Lua
394 lines
11 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
|
|
|
|
-- Find a file-local by name across ALL of a state's exported functions, and one
|
|
-- level into any inner functions they close over.
|
|
--
|
|
-- Safe because a Lua file-local is a single shared upvalue: every closure in that
|
|
-- file that references it sees the same variable, so whichever function we reach
|
|
-- it through yields the same value. This saves hand-picking a probe function per
|
|
-- descriptor, and survives the common case where the local we want is touched by
|
|
-- draw() or an inner helper rather than by keypressed().
|
|
function menus.getAnyUpvalue(stateTable, name)
|
|
if type(stateTable) ~= "table" then
|
|
return nil
|
|
end
|
|
|
|
local nested = {}
|
|
|
|
for _, fn in pairs(stateTable) do
|
|
if type(fn) == "function" then
|
|
local value = getUpvalue(fn, name)
|
|
if value ~= nil then
|
|
return value
|
|
end
|
|
|
|
local i = 1
|
|
while true do
|
|
local upName, upValue = debug.getupvalue(fn, i)
|
|
if not upName then
|
|
break
|
|
end
|
|
if type(upValue) == "function" then
|
|
nested[#nested + 1] = upValue
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, fn in ipairs(nested) do
|
|
local value = getUpvalue(fn, name)
|
|
if value ~= nil then
|
|
return value
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- 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
|
|
|
|
-- Does a file-local of this name EXIST, regardless of its current value?
|
|
--
|
|
-- getUpvalue returns the value, so it cannot tell "no such local" from "local
|
|
-- exists but is currently nil". That distinction matters for the self-test:
|
|
-- several states declare `local options` at the top and only assign it in
|
|
-- open(), so before the screen is first visited the value is legitimately nil.
|
|
-- Treating that as a broken probe produced a false alarm on worldmap.
|
|
function menus.hasUpvalue(stateTable, name)
|
|
if type(stateTable) ~= "table" then
|
|
return false
|
|
end
|
|
|
|
local function scan(fn)
|
|
local i = 1
|
|
while true do
|
|
local upName = debug.getupvalue(fn, i)
|
|
if not upName then
|
|
return false
|
|
end
|
|
if upName == name then
|
|
return true
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
|
|
local nested = {}
|
|
|
|
for _, fn in pairs(stateTable) do
|
|
if type(fn) == "function" then
|
|
if scan(fn) then
|
|
return true
|
|
end
|
|
|
|
local i = 1
|
|
while true do
|
|
local upName, upValue = debug.getupvalue(fn, i)
|
|
if not upName then
|
|
break
|
|
end
|
|
if type(upValue) == "function" then
|
|
nested[#nested + 1] = upValue
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, fn in ipairs(nested) do
|
|
if scan(fn) then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- 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
|
|
|
|
-- Try the named probe function first, then fall back to searching every
|
|
-- function in the state. Several states (actions_menu, worldmap) build their
|
|
-- list in open() and never mention it in keypressed, so the named function
|
|
-- alone finds nothing.
|
|
local probeFn = stateTable[probe.fn or "keypressed"]
|
|
|
|
local index = getUpvalue(probeFn, probe.focus)
|
|
if index == nil then
|
|
index = menus.getAnyUpvalue(stateTable, probe.focus)
|
|
end
|
|
|
|
local list = getUpvalue(probeFn, probe.list)
|
|
if list == nil then
|
|
list = menus.getAnyUpvalue(stateTable, probe.list)
|
|
end
|
|
|
|
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
|
|
|
|
-- Descriptors that suppress repetition across reads (journal only leads with
|
|
-- the tab or the entry title when it CHANGED) keep that memory in file-locals,
|
|
-- which outlive the screen. Without this, reopening the journal on the entry
|
|
-- you left it on would read the page text with no title in front of it.
|
|
local descriptor = loadDescriptor(stateName)
|
|
if descriptor and descriptor.reset then
|
|
pcall(descriptor.reset)
|
|
end
|
|
|
|
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
|
|
-- A read() descriptor has no probe table, so it would otherwise lose
|
|
-- the version-bump canary entirely -- yet those are exactly the
|
|
-- screens that reach for the most upvalues. `probeNames` lets one
|
|
-- declare which game locals it depends on and get the same check.
|
|
local names = descriptor.probeNames
|
|
if type(names) ~= "table" then
|
|
results[stateName] = "custom read()"
|
|
else
|
|
local stateTable = getStateTable(stateName)
|
|
if not stateTable then
|
|
results[stateName] = "STATE NOT LOADABLE"
|
|
else
|
|
local missing
|
|
for _, name in ipairs(names) do
|
|
if not menus.hasUpvalue(stateTable, name) then
|
|
missing = name
|
|
break
|
|
end
|
|
end
|
|
|
|
if missing then
|
|
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(missing) .. "'"
|
|
else
|
|
local first = menus.getAnyUpvalue(stateTable, names[1])
|
|
results[stateName] = first == nil
|
|
and "custom read(), ok (unset until opened)"
|
|
or "custom read(), ok"
|
|
end
|
|
end
|
|
end
|
|
else
|
|
local stateTable = getStateTable(stateName)
|
|
if not stateTable then
|
|
results[stateName] = "STATE NOT LOADABLE"
|
|
else
|
|
-- Check that the NAMES exist, not that they currently hold values.
|
|
-- Most of these are assigned in open(), so before the screen has
|
|
-- been visited they are legitimately nil.
|
|
local hasFocus = menus.hasUpvalue(stateTable, descriptor.probe.focus)
|
|
local hasList = menus.hasUpvalue(stateTable, descriptor.probe.list)
|
|
|
|
if not hasList then
|
|
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.list) .. "'"
|
|
elseif not hasFocus then
|
|
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.focus) .. "'"
|
|
else
|
|
local list = menus.getAnyUpvalue(stateTable, descriptor.probe.list)
|
|
results[stateName] = list == nil and "ok (unset until opened)" or "ok"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return results
|
|
end
|
|
|
|
return menus
|