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