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
|