Phase 3: object tracker
Game-file footprint unchanged: still one line in main.lua.
Three-level navigation as specified in D2: Ctrl+PageUp/Down cycles categories,
PageUp/Down cycles kinds, Shift+PageUp/Down cycles instances of that kind, Home
announces where it is.
- categories.lua: flag-first classification into Creatures, Items, Alchemy,
Interactables, Exits and Terrain features, reusing the game's own 'exit' tag
and excluding the six nameless mechanical templates.
- tracker.lua: scan, cache, navigation, announcements.
Caching keys on level name, player position and turn: position because
everything sorts by distance to the player, turn because creatures move and
containers empty. Scans run on keypress, never per frame.
FoV is respected throughout -- creatures only when currently visible, tiles when
visible or remembered -- so the tracker never becomes a cheat.
Home reports path length alongside the offset, via the game's own
calculatePathFromPlayer. Straight-line distance is misleading indoors, where a
chest three tiles north through a wall can be twenty steps away.
Corrects D4 in docs/05-decisions.md. I had claimed tile.tryInteract cleanly
separates 'the player can do something here' from scenery. It does not: 'wall'
carries a flavour-only tryInteractFunction ('You touch the wall. It's cold and
rough.'), so every wall segment was promoted into Interactables -- 13 in the
starting house, burying the chest and cauldron. Structural tiles
(blocksMovement and blocksVision) are now excluded before the tryInteract test;
containers and furniture block movement but not sight, so they survive.
12 kinds/19 instances instead of 13/32.
13/13 testbed checks, history unit tests passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8b1eb44c38
commit
c17c5277d4
@@ -0,0 +1,358 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user