From c17c5277d4047faabbfe0684cc6652a0f3acb0ce Mon Sep 17 00:00:00 2001 From: Accessibility Mod Date: Sat, 1 Aug 2026 21:34:13 +0200 Subject: [PATCH] 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 --- accessibility/categories.lua | 173 ++++++++++++++ accessibility/docs/05-decisions.md | 41 +++- accessibility/docs/scratch.md | 58 ++++- accessibility/init.lua | 1 + accessibility/testbed.lua | 63 ++++- accessibility/tracker.lua | 358 +++++++++++++++++++++++++++++ 6 files changed, 687 insertions(+), 7 deletions(-) create mode 100644 accessibility/categories.lua create mode 100644 accessibility/tracker.lua diff --git a/accessibility/categories.lua b/accessibility/categories.lua new file mode 100644 index 0000000..5bd7d63 --- /dev/null +++ b/accessibility/categories.lua @@ -0,0 +1,173 @@ +-- categories.lua — classifies map contents into tracker categories. +-- +-- The game's own `templatesByTag` defines exactly one tag ("exit"), so the +-- tracker needs its own scheme. Per docs/05-decisions.md D4 this is deliberately +-- FLAG-FIRST rather than a hand-listed set of template IDs: +-- +-- * flags are authoritative -- modules/tile.lua:234 defines +-- `tile.tryInteract = (loot or tryInteractFunction) and ...`, which is +-- exactly "the player can do something here" versus scenery; +-- * new content in a future game version falls through to a sensible category +-- instead of vanishing from the tracker. +-- +-- The only hand-maintained parts are the exclusion list and the exit tag reuse. + +local categories = {} + +-------------------------------------------------------------------------------- +-- Exclusions +-------------------------------------------------------------------------------- + +-- The six templates with no name, no description and no parent: invisible +-- mechanics that must never be tracked. (See docs/05-decisions.md D4.) +local excludedTemplates = { + blank = true, + throw_blocker = true, + movement_blocker = true, + fire_detector = true, + wall_features_position_dummy = true, + ladder_down_hidden = true, +} + +-------------------------------------------------------------------------------- +-- Category list (order is the Ctrl+PageUp/PageDown cycle order) +-------------------------------------------------------------------------------- + +categories.list = { + {key = "creatures", name = "Creatures"}, + {key = "items", name = "Items"}, + {key = "alchemy", name = "Alchemy"}, + {key = "interactables", name = "Interactables"}, + {key = "exits", name = "Exits"}, + {key = "terrain", name = "Terrain features"}, +} + +-------------------------------------------------------------------------------- +-- Helpers +-------------------------------------------------------------------------------- + +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 + +local itemTemplates + +local function getItemTemplates() + if itemTemplates then + return itemTemplates + end + + local ok, templates = pcall(require, "modules.item.templates") + itemTemplates = (ok and templates) or {} + return itemTemplates +end + +-- Item category 3 is "Ingredients" (modules/item/categories.lua). +local INGREDIENT_CATEGORY = 3 + +local function lootContainsIngredient(loot) + if type(loot) ~= "table" then + return false + end + + local templates = getItemTemplates() + + for _, entry in ipairs(loot) do + local templateId = type(entry) == "table" and entry.templateId + local template = templateId and templates[templateId] + if template and template.category == INGREDIENT_CATEGORY then + return true + end + end + + return false +end + +local function isLootable(tile) + return tile.loot and tile.loot[1] and not tile.unlootable +end + +-------------------------------------------------------------------------------- +-- Classification +-------------------------------------------------------------------------------- + +-- Returns a category key, or nil if the tile should not be tracked. +function categories.classifyTile(tile) + if not tile or tile.isHidden or not tile.templateId then + return nil + end + + if excludedTemplates[tile.templateId] then + return nil + end + + if getExitTemplates()[tile.templateId] then + return "exits" + end + + if isLootable(tile) then + return lootContainsIngredient(tile.loot) and "alchemy" or "items" + end + + -- Structural tiles are never tracked, even though many of them report + -- tryInteract. + -- + -- `wall` carries a flavour-only tryInteractFunction ("You touch the wall. + -- It's cold and rough."), so the flag-first rule promoted every wall segment + -- into Interactables -- 13 of them in the starting house alone, burying the + -- chest and the cauldron. tryInteract means "something happens if you press + -- interact", NOT "this is worth walking to". + -- + -- blocksMovement AND blocksVision identifies the structural shell (walls, + -- solid rock). Containers and furniture block movement but not sight, so they + -- survive this filter. Walls are covered by the wall tones and the + -- surroundings scan anyway. + if tile.blocksMovement and tile.blocksVision then + return nil + end + + if tile.tryInteract then + return "interactables" + end + + -- Terrain is intentionally narrow: hazards and water only. Including every + -- wall would bury the useful entries under hundreds of identical ones -- and + -- walls are already covered by the wall tones and the surroundings scan. + if tile.isThreatening then + return "terrain" + end + + return nil +end + +-- Water is a property of the level's water map rather than of a tile, so it is +-- classified separately during the scan. +categories.WATER_CATEGORY = "terrain" + +function categories.tileName(tile) + return tile.name or tile.templateId or "something" +end + +function categories.actorName(actor) + return actor.nameObserved or actor.name or actor.templateId or "someone" +end + +-- Kind key: groups instances of the same thing together. Template id is stabler +-- than the display name (two templates can share a name). +function categories.tileKind(tile) + return tile.templateId +end + +function categories.actorKind(actor) + return actor.templateIdOnExamination or actor.templateId +end + +return categories diff --git a/accessibility/docs/05-decisions.md b/accessibility/docs/05-decisions.md index e80055e..55b62dd 100644 --- a/accessibility/docs/05-decisions.md +++ b/accessibility/docs/05-decisions.md @@ -109,13 +109,50 @@ Every other template resolves to a name/description and lands in a category. **T | `isPushable` / `isBreakableByHand` | 17 / 17 | Interactables | | `isWallFeature` | 22 | Terrain | -Critically, `modules/tile.lua:234` defines the runtime flag as: +`modules/tile.lua:234` defines the runtime flag as: ```lua self.tryInteract = (self.loot or self.tryInteractFunction) and function(self,actor) ... end ``` -So **`tile.tryInteract` alone cleanly separates "the player can do something here" from scenery** — exactly the flag-first classification proposed in `03-implementation-plan.md`, and it's authoritative rather than heuristic. +> ### ⚠ Correction (found during Phase 3 implementation) +> +> I originally claimed here that **`tile.tryInteract` alone cleanly separates +> "the player can do something here" from scenery.** That is **wrong**, and the +> tracker was visibly worse for it. +> +> Plenty of templates carry a *flavour-only* `tryInteractFunction`. `wall` is the +> worst offender: +> +> ```lua +> wall = { +> name = "a stone wall", +> blocksMovement = true, blocksVision = true, +> tryInteractFunction = function(self, actor) +> console.print("You touch the wall. It's cold and rough.") +> return 1 +> end, +> } +> ``` +> +> So flag-first classification promoted **every wall segment** into +> Interactables — 13 of them in the starting house, burying the chest, cauldron +> and alchemy table under identical noise. +> +> `tryInteract` means *"something happens if you press interact"*, **not** +> *"this is worth walking to"*. +> +> **Fix:** exclude structural tiles — `blocksMovement and blocksVision` — before +> the `tryInteract` test. That is the solid shell (walls, rock); containers and +> furniture block movement but not sight, so they survive. Walls are already +> served by the wall tones and the surroundings scan. +> +> Result in the starting house: 13 kinds / 32 instances → **12 kinds / 19 +> instances**, all of them things you would actually want to find. +> +> The flag-first *approach* still holds — it just needs this one structural +> filter ahead of it. The lesson is that flags encode what the game engine does, +> not what the player cares about. **Residual hand-maintained list** is therefore small: the 6 exclusions above, the existing `exit` tag set (`way_out`, `building_door`, `horse`, `ladder_up`, `ladder_down`), and an ingredient-node list to split **Alchemy** out of **Items**. Dozens of entries, not hundreds — and new content in future versions falls through to a sensible default category rather than vanishing. diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 992ece0..65f37a6 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -3,7 +3,7 @@ **Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn. Last updated: 2026-08-01 · Currently: **Phases 0–1 done, menus + dialogue done. -Phase 2 (tile examination) next.** +Phase 3 done. Phase 4 (remaining menus) next.** **Game is now playable start-to-finish by ear** from main menu through the intro conversation into gameplay — verified by the testbed, 9/9 checks. @@ -127,6 +127,10 @@ needs a human at the keyboard — Talon to test. | `/` | Scan all 8 neighbours, clockwise from north (terse) | | `;` | Where am I — level, in-game time, coordinates | | `Ctrl`+numpad 1–9 | Probe that direction without moving (`Ctrl+kp5` = standing on) | +| `Ctrl+PageUp` / `Ctrl+PageDown` | Tracker: previous / next **category** | +| `PageUp` / `PageDown` | Tracker: previous / next **kind** | +| `Shift+PageUp` / `Shift+PageDown` | Tracker: previous / next **instance** | +| `Home` | Tracker: distance, direction and path length to current instance | Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits while examining, `j` journal, `i` inventory, `c` character, `a` actions menu. @@ -278,10 +282,56 @@ require time — reading its public fields is the decoupled route.)* `love.event.push`, so without this seam every modifier-gated hotkey would be untestable. The testbed swaps them per keypress. +### Done — Phase 3: object tracker + +- `accessibility/categories.lua` — flag-first classification into the six + categories, plus the 6-template exclusion list and reuse of the game's `exit` + tag. +- `accessibility/tracker.lua` — scan, cache, three-level navigation, announcements. + +Verified in the starting house: +``` +Interactables, 12 kinds +a bed, 1, nearest 1 tile south-west, 2 of 12 +an open door 1 of 1, 2 tiles north-west +an open door, 2 tiles north-west, 2 steps by path <- Home +``` + +**Caching:** signature is `level name : player x : player y : turn`. Position +matters because everything sorts by distance; turn matters because creatures move +and containers empty. Scans run on keypress only, never per frame. + +**FoV respected** — creatures only when currently visible, tiles when visible or +remembered. The tracker never reveals what a sighted player couldn't see. + +**`Home` reports path length, not just offset.** Straight-line distance is badly +misleading indoors: a chest 3 tiles north through a wall can be 20 steps away. +Uses the game's own `calculatePathFromPlayer`. + +#### ⚠ Correction to D4 — `tryInteract` is NOT a clean "worth tracking" signal +`wall` carries a **flavour-only** `tryInteractFunction` ("You touch the wall. +It's cold and rough."), so flag-first classification promoted every wall segment +into Interactables — 13 in the starting house, burying the chest and cauldron. +**Fix: exclude structural tiles (`blocksMovement and blocksVision`) before the +`tryInteract` test.** Containers and furniture block movement but not sight, so +they survive. 13 kinds/32 instances → 12 kinds/19 instances. +`docs/05-decisions.md` D4 has been amended. + +*(Not a bug: Items/Alchemy are empty in the tutorial house because +`chest_tutorial` literally prints "find nothing of use for now" — it has no loot +yet. Verify against a later area before trusting those two categories.)* + ### Known gaps (next up) -- **Phase 3: object tracker** — the headline feature, not started. -- Wall tone retrigger fix not yet confirmed by ear. -- `ingame` itself has no descriptor (player status: HP, statuses, current action). +- **Phase 4**: descriptors for `worldmap`, `journal`, `character`, `research`, + `inventory`, `craft`, `refine`, `trade`, `actions_menu`, `alchemy_table`, + `keybindings_menu`, `config_menu`, `menu_text`, `ingame_message`, + `menu_message`. (`inventory`/`craft`/`refine`/`trade` are partly covered + already by the `inventoryCursorPopup` patch.) +- **Phase 5**: player status key (HP, statuses, current action), popup + announcements, damage alerts, mod settings. +- `ingame` itself has no descriptor. +- Tracker categories Items/Alchemy/Exits/Creatures/Terrain unverified against a + populated area — the tutorial house has none of them. - `worldmap`, `journal`, `character`, `research`, `inventory`, `craft`, `refine`, `trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`, `menu_text`, `ingame_message`, `menu_message` still have no descriptors. diff --git a/accessibility/init.lua b/accessibility/init.lua index 3ca8436..5e33d59 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -223,6 +223,7 @@ function accessibility.initialize() pcall(function() require("accessibility.surroundings").registerKeys(hotkeys) + require("accessibility.tracker").registerKeys(hotkeys) end) local ok, err = pcall(function() diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 7a491bf..1907959 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -224,7 +224,68 @@ local function scenario() press("kp6", "probe east", {ctrl = true}) press("kp5", "probe standing-on", {ctrl = true}) - -- The game's own examine mode, now narrated via levelCursorPopup. + -- Phase 3: object tracker. + local tracker = require("accessibility.tracker") + local scanData = tracker.rescan(true) + check("tracker scan returns data", scanData ~= nil, + "scan = " .. tostring(scanData)) + + if scanData then + local summary = {} + for _, category in ipairs(require("accessibility.categories").list) do + local bucket = scanData[category.key] + local kinds = bucket and #bucket.kinds or 0 + local instances = 0 + for _, kind in ipairs(bucket and bucket.kinds or {}) do + instances = instances + #kind.instances + end + summary[#summary + 1] = string.format("%s=%dk/%di", category.key, kinds, instances) + + if kinds > 0 then + local names = {} + for _, kind in ipairs(bucket.kinds) do + names[#names + 1] = kind.name .. "(" .. kind.key .. ")x" .. #kind.instances + end + print("[testbed] " .. category.key .. ": " .. table.concat(names, ", ")) + end + end + print("[testbed] tracker contents: " .. table.concat(summary, " ")) + + local found = false + for _, category in ipairs(require("accessibility.categories").list) do + local bucket = scanData[category.key] + if bucket and #bucket.kinds > 0 then + found = true + end + end + check("tracker found something", found, "at least one non-empty category") + end + + -- Cycle every category so each announcement path runs, including empties. + for i = 1, 6 do + press("pagedown", "tracker: category " .. i, {ctrl = true}) + end + -- Land on a populated category before exercising kind/instance navigation, + -- otherwise every announcement is just "none" and proves nothing. + local trackerCategories = require("accessibility.categories").list + for i, category in ipairs(trackerCategories) do + local bucket = scanData and scanData[category.key] + if bucket and #bucket.kinds > 0 then + -- Cycling wrapped back to index 1, so step forward to this one. + for _ = 2, i do + press("pagedown", "tracker: to " .. category.key, {ctrl = true}) + end + break + end + end + + press("pagedown", "tracker: next kind") + press("pagedown", "tracker: next kind") + press("pagedown", "tracker: next instance", {shift = true}) + press("pagedown", "tracker: next instance", {shift = true}) + press("home", "tracker: announce location") + + -- The game's own examine mode. press("x", "enter examine mode") wait(10) check("entered examine mode", currentState() == "ingame_observe", diff --git a/accessibility/tracker.lua b/accessibility/tracker.lua new file mode 100644 index 0000000..39a5727 --- /dev/null +++ b/accessibility/tracker.lua @@ -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