-- 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 -- -- 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},..}}, .. } } } local scanSignature -------------------------------------------------------------------------------- -- 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 local function addTo(bucket, kindKey, kindName, x, y) 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} 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) 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 return scan 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 -------------------------------------------------------------------------------- -- 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 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 announceKind() end function tracker.cycleInstance(delta) local kind = currentKind() if not kind then announceKind() return end instanceIndex = cycle(instanceIndex, delta, #kind.instances) 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 -------------------------------------------------------------------------------- -- 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) end return tracker