The research tree spoke only the node's name and whether you could afford
it. Everything that tells you whether it is worth affording -- the
knowledge cost, the recipes it unlocks, the description -- is drawn in
the hint windows beside the tree and was never spoken, so "cannot afford"
gave no way to find out what was missing or by how much.
states/research.lua now implements details(), the same = / Shift+= hook
the item grids use: cost with the shortfall spelled out, what it unlocks,
then the description, one block each so [ and ] walk them back. Shift+=
adds the name line and the whole knowledge purse across all four types.
The focus line also takes its name from the node's title instead of
humanising the id, so it is "Sewing techniques", not "sewing techniques".
Everything is read from the state's currentNode upvalue -- the display
table updateCurrentNode() assembles for the hint window -- rather than
models/research.lua, whose nodes table is file-local and whose unlocks
are recipe ids, which would have named the recipe instead of the item it
produces. The shortfall is computed against journalModel.knowledge,
required lazily because models.journal drags in much of the game.
The tree has no hotkey and the prologue may hold no writing desk, so the
testbed enters it the way the desk does: ingame.interface("research"),
the exact call in the desk's tryInteractFunction. Require it as
"states/ingame" and not "states.ingame" -- states.lua uses the slash
form, and the dot form loads a second copy of the module whose
levelWindow is nil, which research.draw indexes on its first frame.
98 testbed checks, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
6.4 KiB
Lua
208 lines
6.4 KiB
Lua
-- details.lua — "tell me more about what is focused".
|
|
--
|
|
-- Focus announcements are deliberately terse: "a torch, slot 1". Everything that
|
|
-- says what an item actually IS -- description, condition, what it refines into,
|
|
-- what a recipe costs, what it sells for -- is on screen but was never spoken.
|
|
--
|
|
--------------------------------------------------------------------------------
|
|
-- Why details settle rather than always speaking
|
|
--------------------------------------------------------------------------------
|
|
--
|
|
-- The game itself gates this text behind a dwell: the cursor popup only fades in
|
|
-- after config.get("examinationPopupFadeInDuration") (default 0.4s). Arrow past a
|
|
-- slot quickly and a SIGHTED player never sees the details either -- they see them
|
|
-- by stopping. So we mirror that rather than inventing a modality: stop on a slot
|
|
-- and the details speak themselves; keep moving and they don't.
|
|
--
|
|
-- `=` reads them on demand and re-reads them, for when you don't want to wait or
|
|
-- the utterance went by. Shift+= is verbose (keeps the name line too).
|
|
--
|
|
--------------------------------------------------------------------------------
|
|
-- The descriptor hook
|
|
--------------------------------------------------------------------------------
|
|
--
|
|
-- descriptor.details(stateTable, menus, verbose) -> { "block", "block", ... }
|
|
--
|
|
-- Optional, alongside read/reset. Returning a LIST rather than one string is what
|
|
-- lets each block be pushed into the console history separately, so [ and ] walk
|
|
-- the detail text back afterwards -- the same trick states/journal.lua uses for
|
|
-- journal pages. A long description therefore never has to be caught first time.
|
|
--
|
|
-- Implemented by the four item-grid screens (_itemgrid.lua) and by the research
|
|
-- tree (states/research.lua). Any other descriptor can gain it for free:
|
|
-- examine-mode tiles are the obvious next one.
|
|
|
|
local speech = require("accessibility.speech")
|
|
local history = require("accessibility.history")
|
|
local menus = require("accessibility.menus")
|
|
|
|
local details = {}
|
|
|
|
details.config = {
|
|
-- How long focus must stay still before details speak, in seconds. Longer
|
|
-- than the game's 0.4s popup delay because speech takes longer than a glance,
|
|
-- and arrowing through a full tab shouldn't leave a backlog behind you.
|
|
settleDelay = 0.6,
|
|
|
|
-- Set false to make details key-only.
|
|
autoSpeak = true,
|
|
}
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Fetching
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Returns the detail blocks for a state, or nil if it has no details to give.
|
|
function details.get(stateName, verbose)
|
|
local descriptor = menus.loadDescriptor(stateName)
|
|
if not descriptor or not descriptor.details then
|
|
return nil
|
|
end
|
|
|
|
local stateTable = menus.getStateTable(stateName)
|
|
if not stateTable then
|
|
return nil
|
|
end
|
|
|
|
local ok, blocks = pcall(descriptor.details, stateTable, menus, verbose)
|
|
if not ok or type(blocks) ~= "table" or #blocks == 0 then
|
|
return nil
|
|
end
|
|
|
|
return blocks
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Speaking
|
|
--------------------------------------------------------------------------------
|
|
|
|
local function currentState()
|
|
local ok, states = pcall(require, "states")
|
|
if not ok then
|
|
return nil
|
|
end
|
|
return states.getCurrent()
|
|
end
|
|
|
|
-- Mirror into the review buffer so [ and ] can walk the blocks after the
|
|
-- utterance has gone by.
|
|
local function pushToHistory(blocks)
|
|
for _, block in ipairs(blocks) do
|
|
pcall(history.append, block, "details")
|
|
end
|
|
end
|
|
|
|
local function speakBlocks(blocks, interrupt)
|
|
pushToHistory(blocks)
|
|
speech.say(table.concat(blocks, ". "), interrupt)
|
|
end
|
|
|
|
-- The `=` key. Interrupting, because the player explicitly asked.
|
|
function details.speak(verbose)
|
|
local stateName = currentState()
|
|
local blocks = stateName and details.get(stateName, verbose)
|
|
|
|
if not blocks then
|
|
-- Never a silent no-op: silence is indistinguishable from a dropped key.
|
|
speech.say("No details", true)
|
|
return
|
|
end
|
|
|
|
speakBlocks(blocks, true)
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- The settle timer
|
|
--------------------------------------------------------------------------------
|
|
|
|
local settledKey -- focus key currently being timed
|
|
local settledTime -- how long it has been unchanged
|
|
local spokenKey -- focus key we have already spoken details for
|
|
local lastState -- so leaving a screen and coming back speaks again
|
|
|
|
-- Pure, and exposed for the testbed: advance the timer for `key` by `dt` and say
|
|
-- whether details are due now. Due exactly once per focus key -- without that,
|
|
-- a stationary cursor would re-speak every frame.
|
|
function details.settleStep(key, dt)
|
|
if key ~= settledKey then
|
|
settledKey = key
|
|
settledTime = 0
|
|
return false
|
|
end
|
|
|
|
if key == nil or key == spokenKey then
|
|
return false
|
|
end
|
|
|
|
settledTime = (settledTime or 0) + (dt or 0)
|
|
|
|
if settledTime < details.config.settleDelay then
|
|
return false
|
|
end
|
|
|
|
spokenKey = key
|
|
return true
|
|
end
|
|
|
|
function details.reset()
|
|
settledKey = nil
|
|
settledTime = nil
|
|
spokenKey = nil
|
|
end
|
|
|
|
-- Called once per frame from init.lua's update hook.
|
|
function details.update(dt)
|
|
if not details.config.autoSpeak then
|
|
return
|
|
end
|
|
|
|
local stateName = currentState()
|
|
if not stateName then
|
|
return
|
|
end
|
|
|
|
-- The focus key is state-qualified, so without this, leaving a screen and
|
|
-- returning to the same slot would be "already spoken" and stay silent.
|
|
if stateName ~= lastState then
|
|
lastState = stateName
|
|
details.reset()
|
|
end
|
|
|
|
local descriptor = menus.loadDescriptor(stateName)
|
|
if not descriptor or not descriptor.details then
|
|
details.reset()
|
|
return
|
|
end
|
|
|
|
-- Only the focus KEY is wanted here; readFocus's text is what read() already
|
|
-- announced and must not be repeated.
|
|
local _, key = menus.readFocus(stateName)
|
|
|
|
-- No focus key means the descriptor could not read the screen this frame
|
|
-- (mid-transition, window not built yet). Don't time an unknown.
|
|
if key == nil then
|
|
details.reset()
|
|
return
|
|
end
|
|
|
|
if not details.settleStep(stateName .. "|" .. key, dt) then
|
|
return
|
|
end
|
|
|
|
local blocks = details.get(stateName, false)
|
|
if not blocks then
|
|
return
|
|
end
|
|
|
|
-- Queued, not interrupting: this is background awareness the player did not
|
|
-- ask for, so anything they DID ask for outranks it.
|
|
speakBlocks(blocks, false)
|
|
end
|
|
|
|
function details.registerKeys(hotkeys)
|
|
hotkeys.bind("=", nil, function() details.speak(false) end)
|
|
hotkeys.bind("=", "shift", function() details.speak(true) end)
|
|
end
|
|
|
|
return details
|