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