diff --git a/accessibility/README.md b/accessibility/README.md index 92c18a7..39b5824 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -73,6 +73,7 @@ Finds everything you have seen, sorted by distance. | `PageUp` / `PageDown` | Previous / next **kind** of thing in that category | | `Shift+PageUp` / `Shift+PageDown` | Previous / next **instance** of that kind | | `Home` | Distance, direction, and number of steps by path | +| `Ctrl+Home` | Walk to it | Categories: Creatures, Items, Alchemy, Interactables, Exits, Terrain features. @@ -81,6 +82,18 @@ So `Ctrl+PageDown` to reach "Creatures", `PageDown` to move between rat / beetle you the walking distance, which indoors is often very different from the straight-line distance. +`Ctrl+Home` does exactly what left-clicking the tile does with a mouse: it queues +a walk along the game's own path. If the thing itself cannot be stood on — a +creature, a chest, a bed — you walk to the nearest tile beside it instead, and +the announcement says "next to" rather than "to". As with a mouse click, the walk +stops early if something interrupts you, and while an enemy is in view the game +only ever commits one step at a time. + +Your selection sticks. The list is rebuilt and re-sorted by distance every turn, +but the tracker follows the thing you picked rather than the slot it was in, so a +rat you are tracking stays selected as it moves around. It is only let go when it +genuinely stops being visible. + The tracker only ever reports things you have actually seen. ### Your character diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 0c025cb..1787349 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -140,6 +140,7 @@ and by Talon at the keyboard (wall tones). | `PageUp` / `PageDown` | Tracker: previous / next **kind** | | `Shift+PageUp` / `Shift+PageDown` | Tracker: previous / next **instance** | | `Home` | Tracker: distance, direction and path length to current instance | +| `Ctrl+Home` | Tracker: walk to current instance (the left-click path) | | `'` | Health and active effects | | `,` | Current selected action | @@ -178,9 +179,9 @@ even while the camera is panned away. Step detection **polls** `playerActor.x/y` each frame instead of hooking movement — free, and covers walking, pathing, portals and pushes by construction. -⚠ `config.louderWhenFurther = true` as requested, but this inverts the usual -convention (proximity cues normally get louder as you approach, and a near wall is -the more urgent fact). Flip the flag in `walltones.lua` if it reads backwards. +Volume follows the usual proximity convention: a near wall is loud +(`nearVolume`), one out at `radius` is quiet (`farVolume`). Set +`config.louderWhenFurther = true` in `walltones.lua` to invert it. Respects `FoV`/`rememberedFoV` — never leaks unseen map geometry. @@ -319,6 +320,32 @@ remembered. The tracker never reveals what a sighted player couldn't see. misleading indoors: a chest 3 tiles north through a wall can be 20 steps away. Uses the game's own `calculatePathFromPlayer`. +**`Ctrl+Home` walks there.** The game does support click-to-path: `ingame.mousepressed` +(`states/ingame.lua:661`) builds `calculatePathFromPlayer(cursorX, cursorY)` and +feeds it to `level.bufferInput("move", path, ingame)`, or to `playerInput` directly +when the path is one step. `tracker.walkToSelection` reuses that exact shape with +the tracker's selected tile in place of the cursor, so interruptions, threatened- +player truncation and animation skipping all behave the way a mouse click does. + +Two wrinkles, both handled: + +* `calculatePathFromPlayer` returns nil for an impassable target, and most things + worth tracking (creatures, chests, beds) are impassable. We fall back to the + cheapest of the 8 neighbouring tiles and say "walking next to X". +* Guard on `states.getCurrent() == "ingame"`. In a menu or observe mode the input + buffer belongs to a different state, and handing it moves would be a bug. + +Note `require("states/ingame")` with a slash — that is the module key `states.lua` +uses, and requiring `states.ingame` would load a *second* copy of the state. + +**Selection is sticky across rescans.** The scan is rebuilt every turn and both +lists are sorted by distance, so holding a bare index means one step by the player +silently re-points the selection at a different rat. The tracker instead remembers +identity — the actor table for creatures, coordinates for tiles — and re-derives +the indices in `reanchor()` after each rescan. Selection is dropped only when the +thing stops being reported at all (out of FoV, killed, picked up); the existing +clamp then falls back to the nearest. + #### ⚠ 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 diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index dd4d670..efd5c6f 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -404,6 +404,37 @@ local function scenario() press("pagedown", "tracker: next instance", {shift = true}) press("home", "tracker: announce location") + -- Selection stickiness: the scan is rebuilt from scratch and re-sorted by + -- distance every turn, so re-deriving the selection by index would silently + -- land on a different object. Force a rescan and check we are still on the + -- same one. + local beforeSelection = tracker.currentSelection() + if beforeSelection then + tracker.rescan(true) + local afterSelection = tracker.currentSelection() + check("tracker keeps its selection across a rescan", + afterSelection ~= nil + and afterSelection.kind == beforeSelection.kind + and afterSelection.x == beforeSelection.x + and afterSelection.y == beforeSelection.y, + beforeSelection.kind .. " at " .. beforeSelection.x .. "," .. beforeSelection.y) + + -- Ctrl+Home walks to the selection, the way a left click does. + local _, trackerWorld = pcall(require, "models.world") + local level = trackerWorld and trackerWorld.currentLevel + local walkFrom = level and level.playerActor + local fromX, fromY = walkFrom and walkFrom.x, walkFrom and walkFrom.y + press("home", "tracker: walk to selection", {ctrl = true}) + wait(20) + check("ctrl+home did not break out of the map", currentState() == "ingame", + "state is " .. tostring(currentState())) + if fromX then + local now = level.playerActor + print(string.format("[testbed] ctrl+home moved player %d,%d -> %d,%d", + fromX, fromY, now.x, now.y)) + end + end + -- Phase 4: the menu screens reachable from gameplay. -- -- Each is opened, navigated once, and closed. `back` is bound to x/escape/ @@ -428,6 +459,7 @@ local function scenario() end check(screen.state .. " describes focus", focusText() ~= nil, "focus=" .. tostring(focusText())) + press("escape", "close " .. screen.state) wait(15) end diff --git a/accessibility/tracker.lua b/accessibility/tracker.lua index 39a5727..c932f63 100644 --- a/accessibility/tracker.lua +++ b/accessibility/tracker.lua @@ -6,6 +6,7 @@ -- PageUp / PageDown kind "rat" -- Shift+PageUp / Shift+PageDown instance that specific rat -- Home announce distance + direction +-- Ctrl+Home walk there (what a left click does) -- -- Two hard rules: -- @@ -28,9 +29,15 @@ local categoryIndex = 1 local kindIndex = 1 local instanceIndex = 1 -local scan -- { [categoryKey] = { kinds = { {key, name, instances={{x,y},..}}, .. } } } +local scan -- { [categoryKey] = { kinds = { {key, name, instances={{x,y,ref},..}}, .. } } } local scanSignature +-- 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 + -------------------------------------------------------------------------------- -- Scanning -------------------------------------------------------------------------------- @@ -66,7 +73,9 @@ local function newBucket() return {kinds = {}, byKind = {}} end -local function addTo(bucket, kindKey, kindName, x, y) +-- 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) if not kindKey then return end @@ -79,7 +88,7 @@ local function addTo(bucket, kindKey, kindName, x, y) bucket.kinds[#bucket.kinds + 1] = kind end - kind.instances[#kind.instances + 1] = {x = x, y = y} + kind.instances[#kind.instances + 1] = {x = x, y = y, ref = ref} end function tracker.rescan(force) @@ -110,7 +119,7 @@ function tracker.rescan(force) 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) + categories.actorName(actor), actor.x, actor.y, actor) end end end @@ -166,9 +175,85 @@ function tracker.rescan(force) scan = buckets scanSignature = signature + tracker.reanchor() return scan end +-------------------------------------------------------------------------------- +-- 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 + -------------------------------------------------------------------------------- -- Access -------------------------------------------------------------------------------- @@ -204,6 +289,23 @@ local function currentInstance() return kind.instances[instanceIndex], kind end +-- 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 + -------------------------------------------------------------------------------- -- Announcements -------------------------------------------------------------------------------- @@ -284,6 +386,7 @@ function tracker.cycleCategory(delta) categoryIndex = cycle(categoryIndex, delta, #categories.list) kindIndex = 1 instanceIndex = 1 + remember() announceCategory() end @@ -295,6 +398,7 @@ function tracker.cycleKind(delta) end kindIndex = cycle(kindIndex, delta, #bucket.kinds) instanceIndex = 1 + remember() announceKind() end @@ -305,6 +409,7 @@ function tracker.cycleInstance(delta) return end instanceIndex = cycle(instanceIndex, delta, #kind.instances) + remember() announceInstance() end @@ -338,6 +443,104 @@ function tracker.announceLocation() speech.say(table.concat(parts, ", "), true) end +-- 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 + -------------------------------------------------------------------------------- -- Key registration -------------------------------------------------------------------------------- @@ -353,6 +556,7 @@ function tracker.registerKeys(hotkeys) hotkeys.bind("pagedown", nil, function() tracker.cycleKind(1) end) hotkeys.bind("home", nil, tracker.announceLocation) + hotkeys.bind("home", "ctrl", tracker.walkToSelection) end return tracker diff --git a/accessibility/walltones.lua b/accessibility/walltones.lua index da9274a..1189f5d 100644 --- a/accessibility/walltones.lua +++ b/accessibility/walltones.lua @@ -15,9 +15,9 @@ -- -- i.e. 0.4 world units per tile, listener floating 4 units above the map plane. -- --- If we simply placed each tone at its wall's real position, OpenAL would --- attenuate it by distance -- so a far wall would be QUIETER. That directly --- fights the requested behaviour (far wall = louder). It would also be feeble +-- If we simply placed each tone at its wall's real position, OpenAL's own +-- attenuation curve would decide the volume for us -- unpredictable, and tied to +-- whatever distance model the game happens to set. It would also be feeble -- directionally: with the listener 4 units up and a wall 5 tiles out (2.0 units), -- the vertical offset dominates and the pan collapses toward centre. -- @@ -46,17 +46,12 @@ local config = { -- constant attenuation, not the perceived direction. sourceRadius = 1.0, - -- Volume at the near and far ends of the range. - nearVolume = 0.25, - farVolume = 1.0, + -- Volume for a wall you are standing next to, and for one out at `radius`. + nearVolume = 1.0, + farVolume = 0.25, - -- As requested: a wall further away is LOUDER. - -- - -- Flagging this because it inverts the usual convention -- most proximity cues - -- get louder as you approach, and a nearby wall is the more urgent fact. If it - -- reads backwards in play, set this to false and near walls become the loud - -- ones; nothing else needs to change. - louderWhenFurther = true, + -- Set true to invert: a wall further away becomes the louder one. + louderWhenFurther = false, } walltones.config = config @@ -134,10 +129,11 @@ local function volumeFor(distance) local t = (distance - 1) / math.max(1, config.radius - 1) t = math.max(0, math.min(1, t)) - if not config.louderWhenFurther then + if config.louderWhenFurther then t = 1 - t end + -- t is now 0 at the loud end, 1 at the quiet end. return config.nearVolume + (config.farVolume - config.nearVolume) * t end