406 lines
12 KiB
Lua
406 lines
12 KiB
Lua
-- 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
|