-- 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) -- Shift+End nearest known exit, without touching the above -- -- 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 -------------------------------------------------------------------------------- -- Nearest exit -------------------------------------------------------------------------------- -- -- Exits are already a tracker category, but reaching them costs several keypresses -- of category cycling and clobbers whatever you were tracking. "Where is the way -- out" is asked often enough, and under enough pressure, to deserve one key that -- answers without disturbing the selection. -- -- This does NOT use the tracker's own scan. The game has the exact primitive -- already: level.getListByTag("exit") (modules/level.lua:1382) walks the grid, -- gates on FoV or rememberedFoV, skips hidden tiles and returns the results -- sorted by distance to the player. Reusing it means the FoV rule is enforced by -- vanilla rather than restated here. -- -- Note for anyone tempted by level.exits: it does not exist at runtime. It is -- packed as nil (modules/level.lua:416) and never assigned; `exits` is a -- generator parameter that gets baked into individual tiles. local exitTemplates local function getExitTemplates() if exitTemplates then return exitTemplates end local ok, templatesByTag = pcall(require, "modules.tile.templatesByTag") exitTemplates = (ok and templatesByTag and templatesByTag.exit) or {} return exitTemplates end -- getListByTag returns bare {x, y}, so the name has to come back off the stack. local function exitNameAt(level, x, y) local stack = level.grid and level.grid[x] and level.grid[x][y] if type(stack) ~= "table" then return "exit" end local templates = getExitTemplates() for i = #stack, 1, -1 do local tile = stack[i] if tile and not tile.isHidden and templates[tile.templateId] then return categories.tileName(tile) end end return "exit" end -- Shift+End: the nearest way off this level. function tracker.announceNearestExit() local level = getLevel() local actor = level and level.playerActor if not level or not actor then speech.say("No exits known", true) return end local ok, list = pcall(level.getListByTag, "exit") if not ok or type(list) ~= "table" or not list[1] then speech.say("No exits known", true) return end local nearest = list[1] local parts = { string.format("%s, %s", exitNameAt(level, nearest.x, nearest.y), describe.offset(nearest.x - actor.x, nearest.y - actor.y)), } -- Same reasoning as announceLocation: straight-line offset is misleading -- indoors, and the path count truncates to one step while threatened. local okPath, path = pcall(level.calculatePathFromPlayer, nearest.x, nearest.y) if okPath and path and #path > 0 then parts[#parts + 1] = #path .. (#path == 1 and " step by path" or " steps by path") elseif okPath then parts[#parts + 1] = "no path" end if #list > 1 then parts[#parts + 1] = #list .. " known" 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) hotkeys.bind("end", "shift", tracker.announceNearestExit) end return tracker