2026-08-01 21:34:13 +02:00
|
|
|
-- 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
|
2026-08-02 01:00:25 +02:00
|
|
|
-- Ctrl+Home walk there (what a left click does)
|
2026-08-03 16:05:15 +02:00
|
|
|
-- Shift+End nearest known exit, without touching the above
|
2026-08-01 21:34:13 +02:00
|
|
|
--
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
local scan -- { [categoryKey] = { kinds = { {key, name, instances={{x,y,ref},..}}, .. } } }
|
2026-08-01 21:34:13 +02:00
|
|
|
local scanSignature
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 21:34:13 +02:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
-- 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)
|
2026-08-01 21:34:13 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
kind.instances[#kind.instances + 1] = {x = x, y = y, ref = ref}
|
2026-08-01 21:34:13 +02:00
|
|
|
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),
|
2026-08-02 01:00:25 +02:00
|
|
|
categories.actorName(actor), actor.x, actor.y, actor)
|
2026-08-01 21:34:13 +02:00
|
|
|
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
|
2026-08-02 01:00:25 +02:00
|
|
|
tracker.reanchor()
|
2026-08-01 21:34:13 +02:00
|
|
|
return scan
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 21:34:13 +02:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 21:34:13 +02:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- 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
|
2026-08-02 01:00:25 +02:00
|
|
|
remember()
|
2026-08-01 21:34:13 +02:00
|
|
|
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
|
2026-08-02 01:00:25 +02:00
|
|
|
remember()
|
2026-08-01 21:34:13 +02:00
|
|
|
announceKind()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function tracker.cycleInstance(delta)
|
|
|
|
|
local kind = currentKind()
|
|
|
|
|
if not kind then
|
|
|
|
|
announceKind()
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
instanceIndex = cycle(instanceIndex, delta, #kind.instances)
|
2026-08-02 01:00:25 +02:00
|
|
|
remember()
|
2026-08-01 21:34:13 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-03 16:05:15 +02:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 21:34:13 +02:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- 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)
|
2026-08-02 01:00:25 +02:00
|
|
|
hotkeys.bind("home", "ctrl", tracker.walkToSelection)
|
2026-08-03 16:05:15 +02:00
|
|
|
|
|
|
|
|
hotkeys.bind("end", "shift", tracker.announceNearestExit)
|
2026-08-01 21:34:13 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return tracker
|