-- 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