The item grids said which item you were on ("a torch, slot 3") but nothing
about what it was -- no description, no condition, and no "what does this
refine into", which is the whole of alchemy.
The game already composes all of it in one place: item.getPopupMessage()
(modules/item.lua:199) returns the cursor popup's line list, so read that
rather than re-deriving anything from templates.
It also already gates it behind a dwell -- the popup only fades in after
examinationPopupFadeInDuration (0.4s), so a sighted player who arrows past a
slot never sees the details either. Mirror that instead of inventing a
modality: stop on an item for 0.6s and the details speak themselves, queued.
"=" reads them on demand, Shift+= keeps the name line too. ("r" was the
obvious mnemonic but is a game-claimed scancode, and getCommand() dispatches
on scancode ignoring modifiers.)
New generic hook descriptor.details() returning a LIST of blocks, so each is
also pushed into the console history and [ ] \ walk a long description back
afterwards -- the journal page trick. Only _itemgrid.lua implements it so far.
The other half is the hint panel craft/refine/trade draw permanently beside
the grid, which the popup never contains: recipe components, and price, which
appears nowhere else in the game. The three panels share a shape, so one
reader covers them. Affordability survives only as a red colour in the hint
table and decides whether enter does anything, so it is decoded and spoken.
59 testbed checks, 0 failures: a synthetic ivy_leaves item as the version-bump
canary for getPopupMessage, hasUpvalue over every hint-panel local, four
assertions on the pure settleStep, and an end-to-end "=" press. Not yet
exercised against a live item -- the prologue inventory is empty.
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 so far by the four item-grid screens (_itemgrid.lua). Any other
|
|
-- descriptor can gain it for free: research nodes and examine-mode tiles are the
|
|
-- obvious next ones.
|
|
|
|
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
|