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,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
|
||||||
@@ -109,13 +109,50 @@ Every other template resolves to a name/description and lands in a category. **T
|
|||||||
| `isPushable` / `isBreakableByHand` | 17 / 17 | Interactables |
|
| `isPushable` / `isBreakableByHand` | 17 / 17 | Interactables |
|
||||||
| `isWallFeature` | 22 | Terrain |
|
| `isWallFeature` | 22 | Terrain |
|
||||||
|
|
||||||
Critically, `modules/tile.lua:234` defines the runtime flag as:
|
`modules/tile.lua:234` defines the runtime flag as:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
self.tryInteract = (self.loot or self.tryInteractFunction) and function(self,actor) ... end
|
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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn.
|
**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.
|
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
|
**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.
|
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) |
|
| `/` | Scan all 8 neighbours, clockwise from north (terse) |
|
||||||
| `;` | Where am I — level, in-game time, coordinates |
|
| `;` | Where am I — level, in-game time, coordinates |
|
||||||
| `Ctrl`+numpad 1–9 | Probe that direction without moving (`Ctrl+kp5` = standing on) |
|
| `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
|
Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits
|
||||||
while examining, `j` journal, `i` inventory, `c` character, `a` actions menu.
|
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
|
`love.event.push`, so without this seam every modifier-gated hotkey would be
|
||||||
untestable. The testbed swaps them per keypress.
|
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)
|
### Known gaps (next up)
|
||||||
- **Phase 3: object tracker** — the headline feature, not started.
|
- **Phase 4**: descriptors for `worldmap`, `journal`, `character`, `research`,
|
||||||
- Wall tone retrigger fix not yet confirmed by ear.
|
`inventory`, `craft`, `refine`, `trade`, `actions_menu`, `alchemy_table`,
|
||||||
- `ingame` itself has no descriptor (player status: HP, statuses, current action).
|
`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`,
|
- `worldmap`, `journal`, `character`, `research`, `inventory`, `craft`, `refine`,
|
||||||
`trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`,
|
`trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`,
|
||||||
`menu_text`, `ingame_message`, `menu_message` still have no descriptors.
|
`menu_text`, `ingame_message`, `menu_message` still have no descriptors.
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ function accessibility.initialize()
|
|||||||
|
|
||||||
pcall(function()
|
pcall(function()
|
||||||
require("accessibility.surroundings").registerKeys(hotkeys)
|
require("accessibility.surroundings").registerKeys(hotkeys)
|
||||||
|
require("accessibility.tracker").registerKeys(hotkeys)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
|
|||||||
@@ -224,7 +224,68 @@ local function scenario()
|
|||||||
press("kp6", "probe east", {ctrl = true})
|
press("kp6", "probe east", {ctrl = true})
|
||||||
press("kp5", "probe standing-on", {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")
|
press("x", "enter examine mode")
|
||||||
wait(10)
|
wait(10)
|
||||||
check("entered examine mode", currentState() == "ingame_observe",
|
check("entered examine mode", currentState() == "ingame_observe",
|
||||||
|
|||||||
@@ -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