Files
alchemist-accessibility/accessibility/tracker.lua
T
Accessibility ModandClaude Opus 5 81a209b600 tracker: sticky selection and Ctrl+Home walk-to; walltones: conventional volume
Tracker selection now survives a rescan. The scan is rebuilt every turn and
both lists are distance-sorted, so holding a bare index meant one step by the
player silently re-pointed the selection at a different rat. The tracker now
remembers identity instead -- the actor table for creatures, coordinates for
tiles -- and re-derives the indices in reanchor() after each rescan. Selection
is dropped only when the thing genuinely stops being reported (out of FoV,
killed, picked up), and the existing clamp then falls back to the nearest.

Ctrl+Home walks to the selection, reusing the exact shape of the game's own
click-to-path in ingame.mousepressed: calculatePathFromPlayer, then either
bufferInput or playerInput for a one-step path. Interruptions and threatened-
player truncation therefore behave the way a mouse click does. Most things
worth tracking stand on impassable tiles, which calculatePathFromPlayer refuses
outright, so we fall back to the cheapest of the 8 neighbours and say "next to"
rather than "to". Guarded on states.getCurrent() == "ingame": elsewhere the
input buffer belongs to another state and feeding it moves would be a bug.

Wall tones now follow the usual proximity convention -- a near wall is loud,
one out at radius is quiet. It was inverted, which read backwards in play since
a nearby wall is the more urgent fact. config.louderWhenFurther still flips it.

Testbed covers both: selection identity across a forced rescan, and that
Ctrl+Home moves the player without breaking out of the map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 01:00:25 +02:00

563 lines
16 KiB
Lua

-- tracker.lua — the object tracker.
--
-- Three-level navigation (docs/05-decisions.md D2):
--
-- Ctrl+PageUp / Ctrl+PageDown category "Creatures"
-- PageUp / PageDown kind "rat"
-- Shift+PageUp / Shift+PageDown instance that specific rat
-- Home announce distance + direction
-- Ctrl+Home walk there (what a left click does)
--
-- Two hard rules:
--
-- * Never report anything outside FoV / rememberedFoV. The tracker must not
-- become a cheat -- it exposes what a sighted player could see, no more.
-- * Never scan per frame. A scan walks xMax x yMax; it runs on keypress and is
-- cached until the world changes under it.
local speech = require("accessibility.speech")
local describe = require("accessibility.describe")
local categories = require("accessibility.categories")
local tracker = {}
--------------------------------------------------------------------------------
-- State
--------------------------------------------------------------------------------
local categoryIndex = 1
local kindIndex = 1
local instanceIndex = 1
local scan -- { [categoryKey] = { kinds = { {key, name, instances={{x,y,ref},..}}, .. } } }
local scanSignature
-- What the player last deliberately selected, so a rescan can put the cursor back
-- on it. Indices alone are useless here: the lists are distance-sorted, so a
-- single step re-orders them and a bare index silently selects a different rat.
local selectedKindKey -- string, or nil
local selectedInstance -- {ref = actor} for creatures, {x, y} for tiles
--------------------------------------------------------------------------------
-- Scanning
--------------------------------------------------------------------------------
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
-- Cheap staleness key. Position matters because everything is sorted by distance
-- to the player; turn matters because creatures move and containers empty.
local function signatureFor(level)
local actor = level.playerActor
local turn = level.world and level.world.turn or 0
return string.format("%s:%s:%s:%s",
tostring(level.name), tostring(actor and actor.x), tostring(actor and actor.y), tostring(turn))
end
local function seenAt(level, x, y)
if level.FoV and level.FoV[x] and level.FoV[x][y] then
return true, true
end
if level.rememberedFoV and level.rememberedFoV[x] and level.rememberedFoV[x][y] then
return true, false
end
return false, false
end
local function newBucket()
return {kinds = {}, byKind = {}}
end
-- ref, when given, is the actor table itself: a creature's identity survives it
-- walking, its coordinates do not.
local function addTo(bucket, kindKey, kindName, x, y, ref)
if not kindKey then
return
end
local kind = bucket.byKind[kindKey]
if not kind then
kind = {key = kindKey, name = kindName, instances = {}}
bucket.byKind[kindKey] = kind
bucket.kinds[#bucket.kinds + 1] = kind
end
kind.instances[#kind.instances + 1] = {x = x, y = y, ref = ref}
end
function tracker.rescan(force)
local level = getLevel()
if not level or not level.playerActor then
scan = nil
return nil
end
local signature = signatureFor(level)
if not force and scan and signature == scanSignature then
return scan
end
local buckets = {}
for _, category in ipairs(categories.list) do
buckets[category.key] = newBucket()
end
local px, py = level.playerActor.x, level.playerActor.y
-- Creatures come from the actor list, not the grid.
for _, actor in ipairs(level.actors or {}) do
if actor ~= level.playerActor
and actor.x and actor.y
and actor.visibilityAlpha ~= 0
then
local visible = level.FoV and level.FoV[actor.x] and level.FoV[actor.x][actor.y]
if visible then
addTo(buckets.creatures, categories.actorKind(actor),
categories.actorName(actor), actor.x, actor.y, actor)
end
end
end
-- Tiles.
for x = 1, level.xMax or 0 do
if level.grid[x] then
for y = 1, level.yMax or 0 do
local stack = level.grid[x][y]
if stack and seenAt(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: a cell can hold both a
-- door and a floor, but not two doors worth reporting.
if key and not claimed[key] then
claimed[key] = true
addTo(buckets[key], categories.tileKind(tile),
categories.tileName(tile), x, y)
end
end
if not claimed.terrain then
local ok, isWater = pcall(level.isWaterTile, x, y)
if ok and isWater then
addTo(buckets.terrain, "water", "water", x, y)
end
end
end
end
end
end
-- Sort instances by distance to the player, and kinds by their nearest
-- instance, so both levels of navigation move outward.
local function chebyshev(a)
return math.max(math.abs(a.x - px), math.abs(a.y - py))
end
for _, bucket in pairs(buckets) do
for _, kind in ipairs(bucket.kinds) do
table.sort(kind.instances, function(a, b)
return chebyshev(a) < chebyshev(b)
end)
end
table.sort(bucket.kinds, function(a, b)
return chebyshev(a.instances[1]) < chebyshev(b.instances[1])
end)
end
scan = buckets
scanSignature = signature
tracker.reanchor()
return scan
end
--------------------------------------------------------------------------------
-- Selection stability
--
-- The scan is rebuilt from scratch every turn and both lists are distance-sorted,
-- so the same rat is at a different index each time it or the player moves. We
-- therefore track the selection by identity and re-derive the indices after every
-- rescan. The selection is only dropped when the thing genuinely stops being
-- reported -- out of FoV, killed, picked up.
--------------------------------------------------------------------------------
local function sameInstance(instance, wanted)
if not instance or not wanted then
return false
end
if wanted.ref then
return instance.ref == wanted.ref
end
return instance.ref == nil and instance.x == wanted.x and instance.y == wanted.y
end
-- Record what is selected right now. Called after every deliberate move of the
-- selection, never during a rescan.
local function remember()
local category = categories.list[categoryIndex]
local bucket = scan and category and scan[category.key]
local kind = bucket and bucket.kinds[kindIndex]
selectedKindKey = kind and kind.key or nil
local instance = kind and kind.instances[instanceIndex]
if instance then
selectedInstance = instance.ref and {ref = instance.ref} or {x = instance.x, y = instance.y}
else
selectedInstance = nil
end
end
function tracker.reanchor()
local category = categories.list[categoryIndex]
local bucket = scan and category and scan[category.key]
if not bucket or not selectedKindKey then
return
end
local foundKind
for i, kind in ipairs(bucket.kinds) do
if kind.key == selectedKindKey then
kindIndex, foundKind = i, kind
break
end
end
if not foundKind then
-- The kind is gone from view entirely. Let the clamp in currentKind pick
-- whatever is nearest and stop pretending we still have a selection.
selectedKindKey, selectedInstance = nil, nil
return
end
if not selectedInstance then
return
end
for i, instance in ipairs(foundKind.instances) do
if sameInstance(instance, selectedInstance) then
instanceIndex = i
return
end
end
-- Kind still visible, this particular one is not.
selectedInstance = nil
end
--------------------------------------------------------------------------------
-- Access
--------------------------------------------------------------------------------
local function currentCategory()
return categories.list[categoryIndex]
end
local function currentBucket()
local data = tracker.rescan()
local category = currentCategory()
if not data or not category then
return nil
end
return data[category.key]
end
local function currentKind()
local bucket = currentBucket()
if not bucket or #bucket.kinds == 0 then
return nil
end
kindIndex = math.max(1, math.min(kindIndex, #bucket.kinds))
return bucket.kinds[kindIndex], bucket
end
local function currentInstance()
local kind = currentKind()
if not kind then
return nil
end
instanceIndex = math.max(1, math.min(instanceIndex, #kind.instances))
return kind.instances[instanceIndex], kind
end
-- What is selected right now, after any pending rescan. Used by the testbed, and
-- handy for anything that wants to follow the tracker cursor.
function tracker.currentSelection()
local instance, kind = currentInstance()
if not instance then
return nil
end
return {
category = currentCategory() and currentCategory().key,
kind = kind.key,
name = kind.name,
x = instance.x,
y = instance.y,
ref = instance.ref,
}
end
--------------------------------------------------------------------------------
-- Announcements
--------------------------------------------------------------------------------
local function offsetFromPlayer(instance)
local level = getLevel()
local actor = level and level.playerActor
if not actor then
return ""
end
return describe.offset(instance.x - actor.x, instance.y - actor.y)
end
local function announceCategory()
local category = currentCategory()
local bucket = currentBucket()
if not category then
return
end
if not bucket or #bucket.kinds == 0 then
speech.say(category.name .. ", none", true)
return
end
local count = #bucket.kinds
speech.say(string.format("%s, %d %s", category.name, count,
count == 1 and "kind" or "kinds"), true)
end
local function announceKind()
local kind, bucket = currentKind()
if not kind then
local category = currentCategory()
speech.say((category and category.name or "Nothing") .. ", none", true)
return
end
local total = #kind.instances
local instance = kind.instances[1]
speech.say(string.format("%s, %d, nearest %s, %d of %d",
kind.name, total, offsetFromPlayer(instance), kindIndex, #bucket.kinds), true)
end
local function announceInstance()
local instance, kind = currentInstance()
if not instance then
return
end
speech.say(string.format("%s %d of %d, %s",
kind.name, instanceIndex, #kind.instances, offsetFromPlayer(instance)), true)
end
--------------------------------------------------------------------------------
-- Navigation
--------------------------------------------------------------------------------
local function cycle(value, delta, count)
if count == 0 then
return 1
end
value = value + delta
if value < 1 then
value = count
elseif value > count then
value = 1
end
return value
end
function tracker.cycleCategory(delta)
tracker.rescan()
categoryIndex = cycle(categoryIndex, delta, #categories.list)
kindIndex = 1
instanceIndex = 1
remember()
announceCategory()
end
function tracker.cycleKind(delta)
local bucket = currentBucket()
if not bucket or #bucket.kinds == 0 then
announceKind()
return
end
kindIndex = cycle(kindIndex, delta, #bucket.kinds)
instanceIndex = 1
remember()
announceKind()
end
function tracker.cycleInstance(delta)
local kind = currentKind()
if not kind then
announceKind()
return
end
instanceIndex = cycle(instanceIndex, delta, #kind.instances)
remember()
announceInstance()
end
-- Home: where is the current thing, and how far is it to actually walk there.
function tracker.announceLocation()
local instance, kind = currentInstance()
if not instance then
local category = currentCategory()
speech.say((category and category.name or "Tracker") .. ", nothing selected", true)
return
end
local parts = {
string.format("%s, %s", kind.name, offsetFromPlayer(instance)),
}
-- Straight-line offset can be badly misleading indoors -- a chest three tiles
-- north through a wall may be twenty steps away. Path length is the number
-- that actually matters.
local level = getLevel()
if level then
local ok, path = pcall(level.calculatePathFromPlayer, instance.x, instance.y)
if ok and path and #path > 0 then
parts[#parts + 1] = #path .. (#path == 1 and " step by path" or " steps by path")
elseif ok then
parts[#parts + 1] = "no path"
end
end
speech.say(table.concat(parts, ", "), true)
end
-- Ctrl+Home: actually go there.
--
-- This is the keyboard equivalent of a left click on the tile (states/ingame.lua
-- mousepressed): build a path with calculatePathFromPlayer, then hand it to the
-- level's input buffer with the ingame state as the owner. We deliberately use
-- the game's own two-branch shape -- a one-step path goes through playerInput
-- directly, because bufferInput for a single move re-enters the buffer machinery
-- for no reason.
--
-- Most things worth tracking stand on impassable tiles (a rat, a chest), and
-- calculatePathFromPlayer refuses those outright. So when the tile itself has no
-- path we path to the best neighbouring tile instead, which is what a player
-- clicking a monster expects to happen anyway.
local function pathTo(level, x, y)
local ok, path = pcall(level.calculatePathFromPlayer, x, y)
if ok and path and #path > 0 then
return path, true
end
local best
for dx = -1, 1 do
for dy = -1, 1 do
if dx ~= 0 or dy ~= 0 then
local okNear, near = pcall(level.calculatePathFromPlayer, x + dx, y + dy)
if okNear and near and #near > 0 and (not best or #near < #best) then
best = near
end
end
end
end
return best, false
end
function tracker.walkToSelection()
local instance, kind = currentInstance()
if not instance then
local category = currentCategory()
speech.say((category and category.name or "Tracker") .. ", nothing selected", true)
return
end
-- Walking is only meaningful on the map. In a menu or the observe cursor the
-- input buffer belongs to another state and feeding it moves would be a bug.
local okStates, states = pcall(require, "states")
if not okStates or states.getCurrent() ~= "ingame" then
speech.say("Cannot walk from here", true)
return
end
local level = getLevel()
local actor = level and level.playerActor
if not actor then
return
end
if instance.x == actor.x and instance.y == actor.y then
speech.say(kind.name .. ", already here", true)
return
end
local path, exact = pathTo(level, instance.x, instance.y)
if not path then
speech.say(kind.name .. ", no path", true)
return
end
-- Same module key states.lua uses, so we share its cached instance.
local okIngame, ingame = pcall(require, "states/ingame")
if not okIngame then
return
end
pcall(level.skipAllAnimations)
local okMove, err
if #path == 1 then
okMove, err = pcall(level.playerInput, "move",
actor.x + path[1].dx, actor.y + path[1].dy, ingame)
else
okMove, err = pcall(level.bufferInput, "move", path, ingame)
end
if not okMove then
print("[accessibility] walk to selection failed: " .. tostring(err))
speech.say(kind.name .. ", cannot walk there", true)
return
end
-- calculatePathFromPlayer truncates to a single step while the player is
-- threatened, so the spoken count is the number of steps that will actually
-- be taken, not the distance to the target.
speech.say(string.format("Walking %s %s, %d %s",
exact and "to" or "next to", kind.name,
#path, #path == 1 and "step" or "steps"), true)
end
--------------------------------------------------------------------------------
-- Key registration
--------------------------------------------------------------------------------
function tracker.registerKeys(hotkeys)
hotkeys.bind("pageup", "ctrl", function() tracker.cycleCategory(-1) end)
hotkeys.bind("pagedown", "ctrl", function() tracker.cycleCategory(1) end)
hotkeys.bind("pageup", "shift", function() tracker.cycleInstance(-1) end)
hotkeys.bind("pagedown", "shift", function() tracker.cycleInstance(1) end)
hotkeys.bind("pageup", nil, function() tracker.cycleKind(-1) end)
hotkeys.bind("pagedown", nil, function() tracker.cycleKind(1) end)
hotkeys.bind("home", nil, tracker.announceLocation)
hotkeys.bind("home", "ctrl", tracker.walkToSelection)
end
return tracker