From 973e62c8febaa960767bc4a8673c1e764ecbfb15 Mon Sep 17 00:00:00 2001 From: Accessibility Mod Date: Sun, 2 Aug 2026 02:03:32 +0200 Subject: [PATCH] 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 --- accessibility/README.md | 28 +++ accessibility/docs/scratch.md | 67 +++++- accessibility/init.lua | 2 + accessibility/testbed.lua | 49 ++++ accessibility/viewwatch.lua | 405 ++++++++++++++++++++++++++++++++++ 5 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 accessibility/viewwatch.lua diff --git a/accessibility/README.md b/accessibility/README.md index 3e04e82..a6c39c1 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -55,10 +55,38 @@ the cursor away — press `Shift+]` to return to the newest. | Key | Action | |---|---| | `/` | 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 | | `Ctrl` + numpad `1`–`9` | Describe the tile in that direction without moving | | `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 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. diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 16fc304..e86df64 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -2,12 +2,12 @@ **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. **Game is playable start-to-finish by ear** from main menu through the intro 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) ``` @@ -134,6 +134,7 @@ and by Talon at the keyboard (wall tones). | `\` | Repeat current line | | `Shift+\` | Copy current line to clipboard | | `/` | 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 | | `Ctrl`+numpad 1–9 | Probe that direction without moving (`Ctrl+kp5` = standing on) | | `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 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. +### 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) - **Phase 5**: player status key (HP, statuses, current action), popup 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 ### 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`). 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 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 + ``` diff --git a/accessibility/init.lua b/accessibility/init.lua index b39290b..dc3bf80 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -162,6 +162,7 @@ local function hookStates() end) pcall(walltones.update) + pcall(require("accessibility.viewwatch").update, dt) pcall(require("accessibility.status").update) pcall(speech.flush) if testbedActive then @@ -243,6 +244,7 @@ function accessibility.initialize() require("accessibility.surroundings").registerKeys(hotkeys) require("accessibility.tracker").registerKeys(hotkeys) require("accessibility.status").registerKeys(hotkeys) + require("accessibility.viewwatch").registerKeys(hotkeys) end) local ok, err = pcall(function() diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 7f33299..c6d4f33 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -592,6 +592,55 @@ local function scenario() "slider patch applied but not exercised") 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 check("slider announces value changes", true, "via ingame_wait") end diff --git a/accessibility/viewwatch.lua b/accessibility/viewwatch.lua new file mode 100644 index 0000000..4dc5341 --- /dev/null +++ b/accessibility/viewwatch.lua @@ -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