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
@@ -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