Files
Accessibility ModandClaude Opus 5 93ef26349a loot overlay: speak the contents of visible containers
The tutorial teaches "press s to see the contents of containers", and it was
silent. For a sighted player s sets levelWindow.itemsHighlightEnabled, which
draws the top item's icon on every loot-bearing tile in FoV.

We watch that flag rather than binding s ourselves -- it is game-claimed, and
keybindings.getCommand() ignores modifiers -- and speak on its rising edge, so
the player's own binding, hold vs toggle mode and the toolbar icon all work.
The flag is checked both at end of frame and inside the keypressed wrapper,
because in hold mode a quick tap goes up and down within one frame.

Reads the whole tile.loot list, not just the icon's top entry, nearest first,
with each container pushed to the console history for [ ] review.

71 testbed checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 01:45:18 +02:00

327 lines
9.6 KiB
Lua

-- lootoverlay.lua — speak what the game's loot highlight shows.
--
-- The tutorial says: "Press 's' to see the contents of containers." For a
-- sighted player that turns on `levelWindow.itemsHighlightEnabled`, which draws
-- the icon of the topmost item of every visible container or pile straight onto
-- the map. By ear it did nothing at all — so the one mechanic the game
-- explicitly teaches for finding reagents was unusable, and alchemy starts with
-- finding reagents.
--
-- This announces the same thing in words, and goes one better: the overlay can
-- only draw ONE icon per tile, so it tells a sighted player "there is something
-- in that chest" but not what. We read the whole `tile.loot` list.
--
--------------------------------------------------------------------------------
-- Why this is polled rather than key-bound
--------------------------------------------------------------------------------
--
-- `s` is a game-claimed scancode, and keybindings.getCommand() dispatches on
-- scancode ignoring modifiers, so binding anything on it in our layer would
-- either eat the game's own command or fire alongside a modifier we don't want.
-- Instead we watch `levelWindow.itemsHighlightEnabled` and speak on its rising
-- edge. That is the single flag both `lootOverlayClick` and its mouse-button
-- twin set, so this covers:
--
-- * the key, in hold mode (config `holdForItemHighlight`) and in toggle mode,
-- * clicking the toolbar icon,
-- * `ingame`, `ingame_observe` and both targeting states, which all share the
-- one levelWindow instance (it is created once in ingame.open and passed on
-- through params).
--
-- No game file is touched, and the player's own key stays whatever they have
-- rebound it to.
--
--------------------------------------------------------------------------------
-- FoV
--------------------------------------------------------------------------------
--
-- Membership matches levelWindow's own test exactly: current `FoV` only (not
-- rememberedFoV), tile not hidden, `visibilityAlpha ~= 0`, loot list non-empty.
-- Nothing is reported that is not being drawn on screen at that moment.
local speech = require("accessibility.speech")
local describe = require("accessibility.describe")
local history = require("accessibility.history")
local menus = require("accessibility.menus")
local lootoverlay = {}
local config = {
-- Half-width of the scanned box around the player, in tiles. Sight range is
-- well inside this; the overlay itself only draws within the camera view.
radius = 20,
-- Longest utterance worth producing in one go. A warehouse floor covered in
-- piles would otherwise read for a minute with no way to stop partway.
maxTiles = 12,
}
lootoverlay.config = config
local wasEnabled = false
--------------------------------------------------------------------------------
-- Reading the level
--------------------------------------------------------------------------------
local function getLevel()
local ok, world = pcall(require, "models.world")
if not ok or not world.currentLevel then
return nil
end
return world.currentLevel
end
local function itemTemplates()
local ok, templates = pcall(require, "modules.item.templates")
return ok and templates or nil
end
-- Loot entries are item *parameters* ({templateId, quantity}), not constructed
-- items — modules/lootPopup.lua builds real items from them only when the popup
-- opens. So the name comes from the template, and constructing an item here
-- (which allocates canvases) is avoided.
local function itemText(entry, templates)
if type(entry) ~= "table" then
return nil
end
local template = entry.templateId and templates and templates[entry.templateId]
local name = (template and template.name) or entry.name or entry.templateId
if not name then
return nil
end
local quantity = tonumber(entry.quantity) or 1
-- Quantities can be fractional (distilling yields x0.2), and floatfix leaves
-- long tails behind. Two decimals is what the game's own quantity label shows.
quantity = math.floor(quantity * 100 + 0.5) / 100
if quantity == 1 then
return tostring(name)
end
return tostring(name) .. ", " .. tostring(quantity)
end
-- The tiles the overlay is drawing an icon on, nearest first.
-- Each entry: {x, y, distance, container, items = {text, ...}}
function lootoverlay.scan()
local level = getLevel()
local actor = level and level.playerActor
if not actor or not actor.x or not actor.y then
return nil
end
local templates = itemTemplates()
local px, py = actor.x, actor.y
local xMin = math.max(1, px - config.radius)
local xMax = math.min(level.xMax or 0, px + config.radius)
local yMin = math.max(1, py - config.radius)
local yMax = math.min(level.yMax or 0, py + config.radius)
local found = {}
for x = xMin, xMax do
if level.grid[x] then
for y = yMin, yMax do
local visible = level.FoV and level.FoV[x] and level.FoV[x][y]
local stack = visible and level.grid[x][y]
if stack then
-- Top down, and stop at the first loot-bearing tile: that is the
-- one whose icon levelWindow draws.
for i = #stack, 1, -1 do
local tile = stack[i]
if tile and not tile.isHidden and tile.visibilityAlpha ~= 0
and tile.loot and tile.loot[1]
then
local items = {}
for _, entry in ipairs(tile.loot) do
local text = itemText(entry, templates)
if text then
items[#items + 1] = text
end
end
found[#found + 1] = {
x = x,
y = y,
distance = describe.distance(x - px, y - py),
container = tile.description or tile.name,
unlootable = tile.unlootable and true or false,
items = items,
}
break
end
end
end
end
end
end
table.sort(found, function(a, b)
if a.distance ~= b.distance then
return a.distance < b.distance
end
if a.x ~= b.x then
return a.x < b.x
end
return a.y < b.y
end)
return found, px, py
end
--------------------------------------------------------------------------------
-- Phrasing
--
-- Pure and exposed, so the testbed can assert the wording without capturing
-- speech.
--------------------------------------------------------------------------------
-- One block per container, so the console history can walk them back with [ and ]
-- after the utterance has gone by — a chest with six reagents in it is more than
-- anyone holds in their head on one hearing.
function lootoverlay.blocks(found, px, py)
if not found then
return nil
end
if #found == 0 then
return {"No loot in view"}
end
local blocks = {}
local shown = math.min(#found, config.maxTiles)
for i = 1, shown do
local entry = found[i]
local parts = {}
if entry.container then
-- Descriptions are sentences ("A wooden chest."); the trailing stop would
-- land mid-clause once we append the position.
parts[#parts + 1] = (entry.container:gsub("%.%s*$", ""))
else
parts[#parts + 1] = "something"
end
parts[#parts + 1] = describe.offset(entry.x - px, entry.y - py)
if entry.unlootable then
parts[#parts + 1] = "cannot be taken"
end
if #entry.items > 0 then
parts[#parts + 1] = table.concat(entry.items, ", ")
else
parts[#parts + 1] = "contents unknown"
end
blocks[#blocks + 1] = table.concat(parts, ", ")
end
if #found > shown then
blocks[#blocks + 1] = string.format("and %d more", #found - shown)
end
return blocks
end
function lootoverlay.speak()
local found, px, py = lootoverlay.scan()
if not found then
return
end
local blocks = lootoverlay.blocks(found, px, py)
-- Interrupting: this is a direct answer to a key the player just pressed.
speech.say(table.concat(blocks, ". "), true)
if #found > 0 then
for _, block in ipairs(blocks) do
pcall(history.append, block, "loot")
end
end
end
--------------------------------------------------------------------------------
-- Watching the game's flag
--------------------------------------------------------------------------------
local levelWindow
-- The instance is a file-local of states/ingame, created once in open() and
-- handed to the other ingame states through params. Cached, because
-- getAnyUpvalue walks every function in the state table.
local function getLevelWindow()
if levelWindow then
return levelWindow
end
local stateTable = menus.getStateTable("ingame")
levelWindow = menus.getAnyUpvalue(stateTable, "levelWindow")
return levelWindow
end
-- Only the ingame family draws the overlay, and only there is levelWindow even
-- assigned — so this also keeps getAnyUpvalue from being retried every frame
-- while the player is sitting in the main menu.
local function inGameplay()
local ok, states = pcall(require, "states")
if not ok then
return false
end
local current = states.getCurrent()
return type(current) == "string" and current:sub(1, 6) == "ingame"
end
-- Version-bump canary for the startup self-test. If `levelWindow` is ever
-- renamed in states/ingame.lua this reports it at startup, instead of the loot
-- key going quietly silent halfway through a playthrough.
function lootoverlay.probeStatus()
local stateTable = menus.getStateTable("ingame")
if not stateTable then
return "BROKEN: cannot load states/ingame"
end
if not menus.hasUpvalue(stateTable, "levelWindow") then
return "PROBE BROKEN: no upvalue 'levelWindow' in states/ingame"
end
return menus.getAnyUpvalue(stateTable, "levelWindow")
and "ok" or "ok (unset until gameplay starts)"
end
function lootoverlay.update()
if not inGameplay() then
return
end
local window = getLevelWindow()
if not window then
return
end
local enabled = window.itemsHighlightEnabled and true or false
if enabled and not wasEnabled then
lootoverlay.speak()
end
wasEnabled = enabled
end
return lootoverlay