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:
co-authored by
Claude Opus 5
parent
86c71fc001
commit
973e62c8fe
@@ -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.
|
||||||
|
|||||||
@@ -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 +
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user