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>
327 lines
9.6 KiB
Lua
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
|