Files
alchemist-accessibility/accessibility/details.lua
T

208 lines
6.4 KiB
Lua
Raw Normal View History

2026-08-03 01:31:58 +02:00
-- 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.
2026-08-03 01:31:58 +02:00
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