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>
This commit is contained in:
Accessibility Mod
2026-08-02 02:03:32 +02:00
co-authored by Claude Opus 5
parent 86c71fc001
commit 973e62c8fe
5 changed files with 546 additions and 5 deletions
+28
View File
@@ -55,10 +55,38 @@ the cursor away — press `Shift+]` to return to the newest.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `/` | Scan all eight neighbouring tiles, clockwise from north | | `/` | Scan all eight neighbouring tiles, clockwise from north |
| `Shift+/` | Cycle automatic announcements of what comes in and out of view |
| `;` | Where am I — area name, in-game date and time, coordinates | | `;` | Where am I — area name, in-game date and time, coordinates |
| `Ctrl` + numpad `1`–`9` | Describe the tile in that direction without moving | | `Ctrl` + numpad `1`–`9` | Describe the tile in that direction without moving |
| `Ctrl` + numpad `5` | Describe the tile you are standing on | | `Ctrl` + numpad `5` | Describe the tile you are standing on |
#### Announcing things as they come into view (`Shift+/`)
`/` tells you what is around you *now*. `Shift+/` decides how much of that
happens by itself as you walk, cycling three settings:
- **View announcements when you stop** *(the default)* — changes pile up while
you are moving and are read out once you have stood still for a moment. A
single step reports as soon as the step lands. A ten-tile walk gives you **one
combined summary at the end** rather than ten reports on top of each other,
and it is a *net* change: something that came into view halfway along and went
out again before you stopped is not mentioned.
- **View announcements on every step** — each step reports its own change. Best
when you are creeping along carefully; too talkative for crossing a room.
- **View announcements off**.
You hear things arriving — `In view: a rat, 4 tiles north-east. barrel, 3,
nearest 2 tiles east` — and creatures leaving: `Out of view: a rat`. Only
creatures are reported when they leave. Furniture drops out of sight constantly
as walls come between you and it, and being told the bed is out of view every
other step would bury everything useful; a creature you can no longer see is
worth knowing about.
These announcements are queued rather than urgent, so anything you asked for
yourself is spoken first, and any key that acts in the game clears them.
Nothing outside your current field of view is ever reported.
The game's own examine mode (**`x`**) is also fully spoken: the arrow keys move a The game's own examine mode (**`x`**) is also fully spoken: the arrow keys move a
cursor around the map and each tile is described with its distance and direction cursor around the map and each tile is described with its distance and direction
from you. Press `f` in examine mode to jump between exits. from you. Press `f` in examine mode to jump between exits.
+62 -5
View File
@@ -2,12 +2,12 @@
**Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn. **Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn.
Last updated: 2026-08-01 · Currently: **Phases 0–5 COMPLETE. Handed off for Last updated: 2026-08-02 · Currently: **Phases 0–5 COMPLETE. Handed off for
playtesting.** Remaining work is polish, mod settings and distribution. playtesting.** Remaining work is polish, mod settings and distribution.
**Game is playable start-to-finish by ear** from main menu through the intro **Game is playable start-to-finish by ear** from main menu through the intro
conversation into gameplay, plus journal, character, actions and inventory — conversation into gameplay, plus journal, character, actions and inventory —
verified by the testbed, **29/29 checks**. verified by the testbed, **34/34 checks**.
### Run the testbed (do this after any change) ### Run the testbed (do this after any change)
``` ```
@@ -134,6 +134,7 @@ and by Talon at the keyboard (wall tones).
| `\` | Repeat current line | | `\` | Repeat current line |
| `Shift+\` | Copy current line to clipboard | | `Shift+\` | Copy current line to clipboard |
| `/` | Scan all 8 neighbours, clockwise from north (terse) | | `/` | Scan all 8 neighbours, clockwise from north (terse) |
| `Shift+/` | View watch: cycle off / announce when you stop / announce every step |
| `;` | Where am I — level, in-game time, coordinates | | `;` | Where am I — level, in-game time, coordinates |
| `Ctrl`+numpad 1–9 | Probe that direction without moving (`Ctrl+kp5` = standing on) | | `Ctrl`+numpad 1–9 | Probe that direction without moving (`Ctrl+kp5` = standing on) |
| `Ctrl+PageUp` / `Ctrl+PageDown` | Tracker: previous / next **category** | | `Ctrl+PageUp` / `Ctrl+PageDown` | Tracker: previous / next **category** |
@@ -502,9 +503,65 @@ page with no title in front of it.
Verified live: `Journal. Quests, A simple commission, 1 of 1, Someone's banging Verified live: `Journal. Quests, A simple commission, 1 of 1, Someone's banging
at my door. Whoever it is, this person is very persistent and doesn't know about at my door. Whoever it is, this person is very persistent and doesn't know about
doorbells.` **29/29 testbed checks**, including a new one that asserts the spoken doorbells.` **34/34 testbed checks**, including a new one that asserts the spoken
text actually contains the page body rather than merely being non-nil. text actually contains the page body rather than merely being non-nil.
### Done — view watch: what comes in and out of view while walking (Talon request)
**`accessibility/viewwatch.lua`**, toggled with **`Shift+/`** — a natural pair
with `/` (scan around me now): `/` is "what is here", `Shift+/` sets how much of
that happens by itself.
Three modes, cycled by the key:
| Mode | Behaviour |
|---|---|
| `settled` (**default**) | Changes accumulate while moving; spoken once the player has stood still for `settleDelay` (0.4 s). |
| `always` | Every step reports its own change. |
| `off` | — |
**Why `settled` is the default and why it diffs against the last announcement,
not the last step.** Talon's question was "announce as they happen, or combine".
Combining is right for a long walk, but only if the baseline is the state at the
last *announcement* — then the summary is a genuine **net** change: something
that came into view mid-walk and left again before you stopped is correctly not
mentioned, and something that stayed in view throughout is mentioned once. A
per-step diff summed up would say both, which is worse than either mode. A
single step is the degenerate case: you are already standing still, so it
reports 0.4 s after the step lands.
**Arrivals and departures are deliberately asymmetric.** Membership is **current
`FoV` only** (never `rememberedFoV` — the question is what you can see from
where you stand). 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 pure
noise. So arrivals are reported for every watched category; departures only for
`config.announceGone`, which is **creatures only** by default.
Other decisions:
- Terrain is **not** watched by default (`config.watched.terrain = false`) —
water arrives dozens of tiles at a time and would drown everything else.
Classification is otherwise the tracker's (`categories.classifyTile`), so the
wall-exclusion correction to D4 applies here too.
- Scan is a **box of `radius` 14 tiles** around the player, not the whole grid.
Sight range is well inside that, and this runs on every step.
- Step detection **polls `playerActor.x/y`**, same as `walltones` — covers
walking, pathing, portals and pushes with no game function wrapped. The scan
runs only when the position changed or a settle is due, never per frame
(invariant 5).
- Entering a new level **re-baselines in silence** rather than reading the whole
first room; so does any mode change.
- Speech is **queued, not interrupting** — this is background awareness, so
anything the player asked for outranks it.
- Announces only in `ingame` / `ingame_observe`.
**Testability seam:** `viewwatch.snapshot()` and `viewwatch.diffText(prev, cur,
px, py)` are pure and exposed, so the testbed asserts the wording and the FoV
invariant directly instead of trying to capture speech. **34/34 testbed checks**
(5 new). Verified live during the run — the walk to the bed produced
`In view: a chest, 1 tile south-west. a torch on the wall, 3, nearest 2 tiles
south-west. …`.
### Known gaps (next up) ### Known gaps (next up)
- **Phase 5**: player status key (HP, statuses, current action), popup - **Phase 5**: player status key (HP, statuses, current action), popup
announcements (`popupStack.add`), damage alerts announcements (`popupStack.add`), damage alerts
@@ -569,7 +626,7 @@ utterance — speech is verifiable from the log, not only by ear. `!` after
## HANDOFF — read this if you are resuming ## HANDOFF — read this if you are resuming
### State of play ### State of play
Phases 0–5 complete. **23 state descriptors, 5 patches, 29/29 testbed checks**, Phases 0–5 complete. **23 state descriptors, 5 patches, 34/34 testbed checks**,
history unit tests passing, **one line changed in the game** (`main.lua`). history unit tests passing, **one line changed in the game** (`main.lua`).
Everything in the original brief is implemented: console speech with Everything in the original brief is implemented: console speech with
@@ -580,7 +637,7 @@ exploration, wall tones, and keyboard+speech access to every screen.
``` ```
lua accessibility/tests/test_history.lua # pure Lua, instant lua accessibility/tests/test_history.lua # pure Lua, instant
rm -rf ~/AppData/Roaming/LOVE/Alchemist # clean save rm -rf ~/AppData/Roaming/LOVE/Alchemist # clean save
"C:/Program Files/LOVE/lovec.exe" . --a11y-test # ~4 min, 29 checks "C:/Program Files/LOVE/lovec.exe" . --a11y-test # ~4 min, 34 checks
git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 + git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 +
``` ```
+2
View File
@@ -162,6 +162,7 @@ local function hookStates()
end) end)
pcall(walltones.update) pcall(walltones.update)
pcall(require("accessibility.viewwatch").update, dt)
pcall(require("accessibility.status").update) pcall(require("accessibility.status").update)
pcall(speech.flush) pcall(speech.flush)
if testbedActive then if testbedActive then
@@ -243,6 +244,7 @@ function accessibility.initialize()
require("accessibility.surroundings").registerKeys(hotkeys) require("accessibility.surroundings").registerKeys(hotkeys)
require("accessibility.tracker").registerKeys(hotkeys) require("accessibility.tracker").registerKeys(hotkeys)
require("accessibility.status").registerKeys(hotkeys) require("accessibility.status").registerKeys(hotkeys)
require("accessibility.viewwatch").registerKeys(hotkeys)
end) end)
local ok, err = pcall(function() local ok, err = pcall(function()
+49
View File
@@ -592,6 +592,55 @@ local function scenario()
"slider patch applied but not exercised") "slider patch applied but not exercised")
end end
------------------------------------------------------------------
-- View watch (Shift+/): what comes in and out of view while walking
--
-- Asserted on the pure seam (snapshot / diffText) rather than on
-- speech, because whether a given step actually reveals anything
-- depends on where the earlier part of the scenario left the player.
-- The invariant and the wording are checkable regardless.
------------------------------------------------------------------
if currentState() == "ingame" then
local viewwatch = require("accessibility.viewwatch")
local snapshot, level = viewwatch.snapshot()
local count = 0
local leaked = nil
for _, entry in pairs(snapshot or {}) do
count = count + 1
if not (level.FoV and level.FoV[entry.x] and level.FoV[entry.x][entry.y]) then
leaked = entry.name
end
end
check("viewwatch sees the room", count > 0, count .. " things in view")
-- Invariant 4: nothing outside FoV may ever be reported.
check("viewwatch reports nothing outside FoV", leaked == nil,
leaked and ("leaked " .. leaked) or "clean")
-- Diff against an empty baseline: everything visible is "entered".
local player = level.playerActor
local text = viewwatch.diffText({}, snapshot, player.x, player.y)
check("viewwatch phrases arrivals",
text ~= nil and text:find("In view: ") == 1,
tostring(text))
-- Nothing changed against itself: no announcement.
check("viewwatch is silent when nothing changed",
viewwatch.diffText(snapshot, snapshot, player.x, player.y) == nil,
"nil")
local startMode = viewwatch.getMode()
for _ = 1, 3 do
viewwatch.cycleMode()
end
check("Shift+/ cycles three modes back to the start",
viewwatch.getMode() == startMode, viewwatch.getMode())
print("[testbed] viewwatch: " .. tostring(text))
end
if sliderTested then if sliderTested then
check("slider announces value changes", true, "via ingame_wait") check("slider announces value changes", true, "via ingame_wait")
end end
+405
View File
@@ -0,0 +1,405 @@
-- 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