Files
Accessibility ModandClaude Opus 5 973e62c8fe viewwatch: announce objects entering and leaving view while walking
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, a rat walks into sight behind you, and none of it is spoken.

Shift+/ cycles three modes:

  settled (default)  changes accumulate while moving, spoken once you have
                     stood still for 0.4s
  always             every step reports its own change
  off

`settled` diffs against the state at the last ANNOUNCEMENT, not the last
step, so a long walk gives one combined summary that is a genuine net
change: something that came into view mid-walk and left again before you
stopped is not mentioned, and something visible throughout is mentioned
once. Summing per-step diffs would report both. A single step is the
degenerate case -- you are already standing still.

Arrivals and departures are deliberately asymmetric. Membership is current
FoV only (never rememberedFoV), but furniture drops out of FoV constantly
indoors as walls come between you and it, and "the bed is out of view"
every other step is noise. Arrivals are reported for every watched
category; departures only for creatures.

Terrain is unwatched by default -- water arrives dozens of tiles at a time.
Step detection polls playerActor.x/y as walltones does, so pathing, portals
and pushes are covered with no game function wrapped; the scan is a 14-tile
box and runs only on a step or a due settle, never per frame.

snapshot() and diffText() are pure and exposed so the testbed can assert
the wording and the FoV invariant directly instead of capturing speech.
34/34 testbed checks, 0 failures. Game footprint unchanged: main.lua | 1 +.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:03:32 +02:00

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