Files
alchemist-accessibility/accessibility/lootoverlay.lua
T

327 lines
9.6 KiB
Lua
Raw Normal View History

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