Files
alchemist-accessibility/accessibility/viewwatch.lua
T

406 lines
12 KiB
Lua
Raw Normal View History

-- viewwatch.lua — announce objects entering and leaving view as you walk.
--
-- Walking blind, the hard part is not "what is next to me" (that is `/`) but
-- "what changed when I moved". Rounding a corner reveals a room; stepping back
-- hides it again; a rat walks into sight behind you. None of that is spoken by
-- the game, and re-scanning by hand after every step is not usable.
--
-- So: diff the set of *currently visible* things across steps and speak the
-- difference.
--
--------------------------------------------------------------------------------
-- The three modes (Shift+/ cycles them)
--------------------------------------------------------------------------------
--
-- off nothing is announced.
-- settled changes accumulate while you are moving and are spoken once you
-- stand still. A single step therefore reports immediately after the
-- step lands, and a ten-tile walk reports ONE combined summary at the
-- end instead of ten overlapping ones. This is the default.
-- always every step reports its own change.
--
-- `settled` diffs against the state at the last *announcement*, not the last
-- step, which is what makes the summary a net change: something that came into
-- view mid-walk and went out again before you stopped is correctly not
-- mentioned, and something that stayed visible throughout is mentioned once.
--
--------------------------------------------------------------------------------
-- Why "visible", and why leaving is treated differently from arriving
--------------------------------------------------------------------------------
--
-- Membership is current FoV only -- rememberedFoV is deliberately NOT used. A
-- remembered tile is knowledge you already have; the question this answers is
-- what you can see from where you are standing now.
--
-- But that cuts both ways. Furniture drops out of FoV constantly indoors as
-- walls come between you and it, and announcing "the bed is out of view" every
-- other step is noise -- you know the bed is still there. A creature leaving
-- view is genuinely worth knowing. So arrivals are reported for every category
-- and departures only for the categories in `config.announceGone`.
--
-- FoV is also the invariant: nothing outside it is ever reported, so this cannot
-- be used to see through walls.
local speech = require("accessibility.speech")
local describe = require("accessibility.describe")
local categories = require("accessibility.categories")
local viewwatch = {}
local config = {
-- How long the player must stand still before `settled` speaks, in seconds.
-- Long enough to bridge the gap between steps of a buffered walk, short
-- enough not to feel laggy after a single step.
settleDelay = 0.4,
-- Half-width of the scanned box around the player, in tiles. Sight range is
-- well inside this; scanning the whole grid every step would be wasteful.
radius = 14,
-- Categories worth reporting at all. Terrain is excluded by default: water
-- tiles come in dozens at a time and would drown everything else.
watched = {
creatures = true,
items = true,
alchemy = true,
interactables = true,
exits = true,
terrain = false,
},
-- Categories where leaving view is also announced. See the note above.
announceGone = {
creatures = true,
},
}
viewwatch.config = config
local modes = {"off", "settled", "always"}
local modeNames = {
off = "View announcements off",
settled = "View announcements when you stop",
always = "View announcements on every step",
}
local modeIndex = 2 -- "settled"
-- The set the next diff is measured against: the last thing we told the player.
local baseline
local lastX, lastY, lastLevel
local stillFor = 0
local dirty = false -- something changed since the last announcement
--------------------------------------------------------------------------------
-- Collecting what is visible
--------------------------------------------------------------------------------
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 isVisible(level, x, y)
return level.FoV and level.FoV[x] and level.FoV[x][y] and true or false
end
-- id -> {name, category, x, y}
--
-- Identity has to survive movement and re-sorting. A creature is identified by
-- its actor table (it walks, its coordinates do not identify it); a tile is
-- identified by template and position (the table itself is rebuilt freely).
local function collect(level)
local actor = level.playerActor
local px, py = actor.x, actor.y
local set = {}
if config.watched.creatures then
for _, other in ipairs(level.actors or {}) do
if other ~= actor and other.x and other.y
and other.visibilityAlpha ~= 0
and isVisible(level, other.x, other.y)
then
set[other] = {
name = categories.actorName(other),
category = "creatures",
x = other.x,
y = other.y,
}
end
end
end
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)
for x = xMin, xMax do
if level.grid[x] then
for y = yMin, yMax do
local stack = level.grid[x][y]
if stack and isVisible(level, x, y) then
local claimed = {}
for i = #stack, 1, -1 do
local tile = stack[i]
local key = categories.classifyTile(tile)
-- One entry per category per cell, matching the tracker: a
-- cell can hold a door and a floor, but not two doors.
if key and config.watched[key] and not claimed[key] then
claimed[key] = true
set[key .. ":" .. x .. ":" .. y] = {
name = categories.tileName(tile),
category = key,
x = x,
y = y,
}
end
end
end
end
end
end
return set
end
--------------------------------------------------------------------------------
-- Phrasing
--------------------------------------------------------------------------------
-- Group entries by spoken name, keeping the nearest one's position, so a corridor
-- of six barrels reads as "6 barrels, nearest 3 tiles east" rather than six
-- separate clauses.
local function group(entries, px, py)
local order, byName = {}, {}
for _, entry in ipairs(entries) do
local distance = describe.distance(entry.x - px, entry.y - py)
local slot = byName[entry.name]
if not slot then
slot = {name = entry.name, count = 0, distance = math.huge}
byName[entry.name] = slot
order[#order + 1] = slot
end
slot.count = slot.count + 1
if distance < slot.distance then
slot.distance = distance
slot.nearest = entry
end
end
table.sort(order, function(a, b)
if a.distance ~= b.distance then
return a.distance < b.distance
end
return a.name < b.name
end)
local parts = {}
for _, slot in ipairs(order) do
local where = describe.offset(slot.nearest.x - px, slot.nearest.y - py)
if slot.count == 1 then
parts[#parts + 1] = slot.name .. ", " .. where
else
parts[#parts + 1] = string.format("%s, %d, nearest %s", slot.name, slot.count, where)
end
end
return parts
end
--------------------------------------------------------------------------------
-- Diffing and announcing
--------------------------------------------------------------------------------
-- The sentence for a change from one visible set to another, or nil if nothing
-- worth saying changed.
--
-- Pure, and exposed, so the testbed can assert on the wording without needing to
-- capture speech: snapshot, step, snapshot, diff.
function viewwatch.diffText(previous, current, px, py)
local entered, gone = {}, {}
for id, entry in pairs(current) do
if not previous[id] then
entered[#entered + 1] = entry
end
end
for id, entry in pairs(previous) do
if not current[id] and config.announceGone[entry.category] then
gone[#gone + 1] = entry
end
end
local parts = {}
if #entered > 0 then
parts[#parts + 1] = "In view: " .. table.concat(group(entered, px, py), ". ")
end
if #gone > 0 then
-- Positions here are where the thing was when last seen, so they are stale
-- by definition -- name only.
local names = {}
for _, entry in ipairs(gone) do
names[#names + 1] = entry.name
end
parts[#parts + 1] = "Out of view: " .. table.concat(names, ", ")
end
if #parts == 0 then
return nil
end
return table.concat(parts, ". ")
end
-- What is visible right now. Exposed for the same reason as diffText.
function viewwatch.snapshot()
local level = getLevel()
if not level or not level.playerActor then
return nil
end
return collect(level), level
end
local function announceChanges(level, current)
local actor = level.playerActor
local text = viewwatch.diffText(baseline, current, actor.x, actor.y)
baseline = current
if not text then
return
end
-- Queued, never interrupting: this is background awareness. Anything the
-- player deliberately asked for outranks it, and any keypress that reaches the
-- game clears the backlog anyway (hotkeys.onGameKey).
speech.say(text, false)
end
--------------------------------------------------------------------------------
-- Step detection
--
-- Polled from the player's coordinates, exactly as walltones does: comparing two
-- integers is free and it covers walking, pathing, portals and pushes without
-- wrapping a single game function.
--
-- The scan itself runs only when the position actually changed (or when a settle
-- is due), never per frame -- invariant 5.
--------------------------------------------------------------------------------
local function announcingStateActive()
local ok, states = pcall(require, "states")
if not ok then
return false
end
local current = states.getCurrent()
return current == "ingame" or current == "ingame_observe"
end
function viewwatch.update(dt)
if modes[modeIndex] == "off" then
return
end
local level = getLevel()
if not level or not level.playerActor then
return
end
local actor = level.playerActor
local x, y = actor.x, actor.y
if not x or not y then
return
end
-- A new level is a total change of scene, not a step: re-baseline in silence
-- rather than reading out the whole first room.
if level ~= lastLevel then
lastLevel = level
lastX, lastY = x, y
baseline = collect(level)
dirty = false
stillFor = 0
return
end
if not baseline then
baseline = collect(level)
end
if x ~= lastX or y ~= lastY then
lastX, lastY = x, y
stillFor = 0
dirty = true
if modes[modeIndex] == "always" and announcingStateActive() then
announceChanges(level, collect(level))
dirty = false
end
return
end
if not dirty or modes[modeIndex] ~= "settled" then
return
end
stillFor = stillFor + (dt or 0)
if stillFor >= config.settleDelay and announcingStateActive() then
dirty = false
announceChanges(level, collect(level))
end
end
--------------------------------------------------------------------------------
-- Mode switching
--------------------------------------------------------------------------------
function viewwatch.getMode()
return modes[modeIndex]
end
function viewwatch.setMode(name)
for i, mode in ipairs(modes) do
if mode == name then
modeIndex = i
-- Re-baseline on every mode change, so switching on mid-walk reports
-- what changes from here rather than dumping everything visible.
local level = getLevel()
baseline = (level and level.playerActor) and collect(level) or nil
dirty = false
stillFor = 0
return
end
end
end
function viewwatch.cycleMode()
viewwatch.setMode(modes[(modeIndex % #modes) + 1])
speech.say(modeNames[modes[modeIndex]], true)
end
function viewwatch.registerKeys(hotkeys)
-- `/` scans what is around you right now; Shift+/ decides how much of that
-- scanning happens by itself.
hotkeys.bind("/", "shift", viewwatch.cycleMode)
end
return viewwatch