diff --git a/accessibility/assets/walltones/walltone_east.wav b/accessibility/assets/walltones/walltone_east.wav index 1458053..9b98445 100644 Binary files a/accessibility/assets/walltones/walltone_east.wav and b/accessibility/assets/walltones/walltone_east.wav differ diff --git a/accessibility/assets/walltones/walltone_north.wav b/accessibility/assets/walltones/walltone_north.wav index 85d577f..341479e 100644 Binary files a/accessibility/assets/walltones/walltone_north.wav and b/accessibility/assets/walltones/walltone_north.wav differ diff --git a/accessibility/assets/walltones/walltone_south.wav b/accessibility/assets/walltones/walltone_south.wav index c4f7f6c..06ae71b 100644 Binary files a/accessibility/assets/walltones/walltone_south.wav and b/accessibility/assets/walltones/walltone_south.wav differ diff --git a/accessibility/assets/walltones/walltone_west.wav b/accessibility/assets/walltones/walltone_west.wav index 3e7ac3e..3bf3a37 100644 Binary files a/accessibility/assets/walltones/walltone_west.wav and b/accessibility/assets/walltones/walltone_west.wav differ diff --git a/accessibility/describe.lua b/accessibility/describe.lua new file mode 100644 index 0000000..ab10513 --- /dev/null +++ b/accessibility/describe.lua @@ -0,0 +1,196 @@ +-- describe.lua — shared spatial text formatting. +-- +-- The game's own descriptions ("A wooden chair.") carry no spatial information, +-- which is fine on screen where position is obvious and useless by ear. Per Q3 +-- we augment rather than merely relay: distance, direction, and passability. +-- +-- Verbosity is a setting because the right amount differs between sweeping the +-- surroundings (terse) and inspecting one tile (verbose). + +local describe = {} + +-- "terse" | "normal" | "verbose" +describe.verbosity = "normal" + +-------------------------------------------------------------------------------- +-- Direction +-------------------------------------------------------------------------------- + +-- Eight-point compass. Screen coordinates: +y is south, +x is east. +function describe.direction(dx, dy) + if dx == 0 and dy == 0 then + return "here" + end + + local parts = {} + + if dy < 0 then + parts[#parts + 1] = "north" + elseif dy > 0 then + parts[#parts + 1] = "south" + end + + if dx < 0 then + parts[#parts + 1] = "west" + elseif dx > 0 then + parts[#parts + 1] = "east" + end + + return table.concat(parts, "-") +end + +-------------------------------------------------------------------------------- +-- Distance +-------------------------------------------------------------------------------- + +-- Chebyshev distance: the number of steps on an 8-directional grid, which is what +-- this game uses. Euclidean would overstate diagonal travel. +function describe.distance(dx, dy) + return math.max(math.abs(dx), math.abs(dy)) +end + +function describe.tiles(n) + return n == 1 and "1 tile" or (n .. " tiles") +end + +-- "3 tiles north-east" / "here" +function describe.offset(dx, dy) + if dx == 0 and dy == 0 then + return "here" + end + + return describe.tiles(describe.distance(dx, dy)) .. " " .. describe.direction(dx, dy) +end + +-------------------------------------------------------------------------------- +-- Tile inspection +-------------------------------------------------------------------------------- + +-- What the player would see standing on / looking at (x,y). +-- Returns a table of facts rather than a sentence, so callers choose the wording. +function describe.tileFacts(level, x, y) + local facts = { + inBounds = false, + seen = false, + visible = false, + blocked = false, + blocksVision = false, + water = false, + hasLoot = false, + interactable = false, + actor = nil, + description = nil, + } + + if not level or not level.grid or not level.grid[x] or not level.grid[x][y] then + return facts + end + + facts.inBounds = true + + local visible = level.FoV and level.FoV[x] and level.FoV[x][y] + local remembered = level.rememberedFoV and level.rememberedFoV[x] and level.rememberedFoV[x][y] + + facts.visible = visible and true or false + facts.seen = (visible or remembered) and true or false + + if not facts.seen then + return facts + end + + if visible then + local ok, actor = pcall(level.getActorOnCoordinates, x, y, function(a) + return a.visibilityAlpha ~= 0 + end) + if ok and actor then + facts.actor = actor + end + end + + for i = #level.grid[x][y], 1, -1 do + local tile = level.grid[x][y][i] + + if tile and not tile.isHidden then + if tile.blocksMovement then + facts.blocked = true + end + if tile.blocksVision then + facts.blocksVision = true + end + if tile.tryInteract then + facts.interactable = true + end + if tile.loot and tile.loot[1] and not tile.unlootable then + facts.hasLoot = true + end + if not facts.description and tile.description then + facts.description = tile.description + end + end + end + + local ok, isWater = pcall(level.isWaterTile, x, y) + facts.water = (ok and isWater) and true or false + + return facts +end + +-- One spoken line for a tile, relative to an origin (usually the player). +function describe.tile(level, x, y, originX, originY) + local facts = describe.tileFacts(level, x, y) + + local dx = originX and (x - originX) or 0 + local dy = originY and (y - originY) or 0 + local where = originX and describe.offset(dx, dy) or nil + + if not facts.inBounds then + return (where and (where .. ", ") or "") .. "edge of the map" + end + + if not facts.seen then + return (where and (where .. ", ") or "") .. "unknown" + end + + local parts = {} + + -- Creatures first: they matter more than the floor they stand on, and the + -- game's own getCursorMessage prioritises them the same way. + if facts.actor then + parts[#parts + 1] = facts.actor.nameObserved or facts.actor.name or "someone" + end + + if facts.description then + parts[#parts + 1] = facts.description + elseif not facts.actor then + parts[#parts + 1] = facts.blocked and "wall" or "floor" + end + + if describe.verbosity ~= "terse" then + if facts.blocked then + parts[#parts + 1] = "blocked" + end + if facts.hasLoot then + parts[#parts + 1] = "has items" + end + if facts.water then + parts[#parts + 1] = "water" + end + end + + if describe.verbosity == "verbose" then + if facts.interactable then + parts[#parts + 1] = "can interact" + end + if not facts.visible then + parts[#parts + 1] = "remembered" + end + end + + if where then + parts[#parts + 1] = where + end + + return table.concat(parts, ", ") +end + +return describe diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index ce6941e..992ece0 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -124,6 +124,12 @@ needs a human at the keyboard — Talon to test. | `Shift+[` / `Shift+]` | Oldest / newest line | | `\` | Repeat current line | | `Shift+\` | Copy current line to clipboard | +| `/` | 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) | + +Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits +while examining, `j` journal, `i` inventory, `c` character, `a` actions menu. Reserved for the tracker (Phase 3): `PageUp`/`PageDown`, `Home`, with Ctrl/Shift. @@ -239,9 +245,43 @@ where you're standing now. `playing` is cleared on level change. *(Mono matters: OpenAL only spatialises mono sources. Stereo would not pan.)* +### Done — Phase 2: tile examination + surroundings + +- `accessibility/describe.lua` — shared spatial formatting. 8-point compass, + **Chebyshev** distance (the game moves 8-directionally, so Euclidean would + overstate diagonals), and `tileFacts()` / `tile()`. Verbosity setting + (`terse`/`normal`/`verbose`) since sweeping 8 neighbours and inspecting one + tile want different amounts of detail. +- `accessibility/states/ingame_observe.lua` — narrates the game's examine cursor. +- `accessibility/surroundings.lua` — probe/scan/where-am-I without moving. +- `accessibility/patches/popups.lua` — wraps `inventoryCursorPopup.set`, covering + **inventory, craft, refine, trade** in one patch. + +**Q3 answered by default: augment, not just relay.** The game's descriptions +("A wooden chair.") carry no position — fine on screen, useless by ear. We append +distance + direction, and blocked/has-items/water at normal verbosity. + +#### Design note: why examine mode is polled, not popup-driven +`levelCursorPopup.set` is called from an **animation-completion callback**, so +unless the player enables the game's `quickExaminationPopup` option the text +lands a full keypress late — in testing you heard the *previous* tile while +standing on the next. The `ingame_observe` descriptor polls `level.cursorX/Y` +instead: same-frame, setting-independent, no game edit. `levelCursorPopup` is +therefore deliberately **not** patched; only `inventoryCursorPopup` is. + +*(The level model is a closure instance, not a module, so it can't be patched at +require time — reading its public fields is the decoupled route.)* + +#### Testability seam +`hotkeys.isCtrlDown` / `isShiftDown` are overridable fields, not direct calls to +`love.keyboard.isScancodeDown`. Real hardware state can't be faked by +`love.event.push`, so without this seam every modifier-gated hotkey would be +untestable. The testbed swaps them per keypress. + ### Known gaps (next up) -- `ingame` itself has no descriptor — that's Phase 2 (tile examination). +- **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). - `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/hotkeys.lua b/accessibility/hotkeys.lua index 45f175b..d9ff484 100644 --- a/accessibility/hotkeys.lua +++ b/accessibility/hotkeys.lua @@ -48,14 +48,26 @@ end -- Modifier helpers -------------------------------------------------------------------------------- -local function ctrlDown() +-- Indirected through overridable fields so the testbed can drive modified +-- hotkeys. love.keyboard.isScancodeDown reads real hardware state, which a +-- synthetic love.event.push cannot influence -- without this seam, every +-- modifier-gated key would be untestable. +hotkeys.isCtrlDown = function() return love.keyboard.isScancodeDown("lctrl", "rctrl") end -local function shiftDown() +hotkeys.isShiftDown = function() return love.keyboard.isScancodeDown("lshift", "rshift") end +local function ctrlDown() + return hotkeys.isCtrlDown() +end + +local function shiftDown() + return hotkeys.isShiftDown() +end + -------------------------------------------------------------------------------- -- Bindings -- diff --git a/accessibility/init.lua b/accessibility/init.lua index 844b246..3ca8436 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -30,6 +30,7 @@ local describedStates = { "new_game", "narration", "ingame_dialogue", + "ingame_observe", } local initialized = false @@ -218,6 +219,11 @@ function accessibility.initialize() initialized = true accessibility.registerPatch("console", require("accessibility.patches.console")) + accessibility.registerPatch("popups", require("accessibility.patches.popups")) + + pcall(function() + require("accessibility.surroundings").registerKeys(hotkeys) + end) local ok, err = pcall(function() speech.initialize() diff --git a/accessibility/patches/popups.lua b/accessibility/patches/popups.lua new file mode 100644 index 0000000..e7a1c88 --- /dev/null +++ b/accessibility/patches/popups.lua @@ -0,0 +1,76 @@ +-- Patch: views/levelCursorPopup.lua and views/inventoryCursorPopup.lua +-- +-- `inventoryCursorPopup.set` is the convergence point for focus text in four +-- screens: inventory, craft, refine and trade. Patching it narrates all four +-- without touching any state file. +-- +-- inventoryCursorPopup.set(lines, x, y, alignX, alignY) -- lines is a TABLE +-- of {text=, icon=} +-- +-- NOTE: levelCursorPopup (the examine cursor) is deliberately NOT handled here. +-- It is set from an animation-completion callback, so its text lands a keypress +-- late unless the player enables the game's "quickExaminationPopup" option. +-- accessibility/states/ingame_observe.lua polls the cursor position instead, +-- which is immediate and setting-independent. + +local speech = require("accessibility.speech") + +-- Suppress repeats: the setter is called on every mouse move, often with +-- unchanged content. +local lastInventoryText + +local function linesToText(lines) + if type(lines) ~= "table" then + return nil + end + + local parts = {} + for _, line in ipairs(lines) do + if type(line) == "table" and line.text then + parts[#parts + 1] = line.text + elseif type(line) == "string" then + parts[#parts + 1] = line + end + end + + if #parts == 0 then + return nil + end + + return table.concat(parts, ". ") +end + +return function() + ---------------------------------------------------------------------------- + -- inventoryCursorPopup: inventory, craft, refine, trade + ---------------------------------------------------------------------------- + local inventoryPopup = require("views.inventoryCursorPopup") + + local realInventorySet = inventoryPopup.set + if not realInventorySet then + error("views.inventoryCursorPopup has no set function -- game structure changed") + end + + inventoryPopup.set = function(lines, ...) + local result = realInventorySet(lines, ...) + + pcall(function() + local text = linesToText(lines) + + if not text then + lastInventoryText = nil + return + end + + text = speech.normalise(text) + if not text or text == lastInventoryText then + return + end + lastInventoryText = text + + speech.say(text, true) + end) + + return result + end +end diff --git a/accessibility/states/ingame_observe.lua b/accessibility/states/ingame_observe.lua new file mode 100644 index 0000000..56d2686 --- /dev/null +++ b/accessibility/states/ingame_observe.lua @@ -0,0 +1,43 @@ +-- states/ingame_observe.lua — the examine cursor. +-- +-- Reads the cursor position straight off the level model rather than listening to +-- levelCursorPopup.set. +-- +-- Why: the popup is set from an animation-completion callback (`onFinish`), so +-- unless the player has turned on the game's "quickExaminationPopup" option, the +-- text arrives only after the cursor finishes gliding. In testing that put each +-- announcement a full keypress behind -- you would hear the previous tile while +-- standing on the next one. Polling cursorX/cursorY announces on the same frame +-- the cursor moves, regardless of that setting, and needs no game edit. +-- +-- The level model is a closure instance rather than a module, so it cannot be +-- patched at require time; reading its public fields is the decoupled route. + +local describe = require("accessibility.describe") + +return { + read = function() + local ok, world = pcall(require, "models.world") + if not ok or not world.currentLevel then + return nil, nil + end + + local level = world.currentLevel + local x, y = level.cursorX, level.cursorY + + if type(x) ~= "number" or type(y) ~= "number" then + return nil, nil + end + + local player = level.playerActor + local px = player and player.x + local py = player and player.y + + local text = describe.tile(level, x, y, px, py) + if not text or text == "" then + return nil, nil + end + + return text, x .. "," .. y + end, +} diff --git a/accessibility/surroundings.lua b/accessibility/surroundings.lua new file mode 100644 index 0000000..50090f1 --- /dev/null +++ b/accessibility/surroundings.lua @@ -0,0 +1,144 @@ +-- surroundings.lua — probe the tiles around the player without moving anything. +-- +-- The game's examine mode (ingame_observe) already walks a cursor around the map +-- and is now narrated, but entering it, stepping, and leaving is heavy when you +-- just want to know what is immediately next to you. This answers that in one +-- keypress, with no state change and no game side effects. + +local speech = require("accessibility.speech") +local describe = require("accessibility.describe") + +local surroundings = {} + +-- Screen coordinates: +y is south. +local numpadDirections = { + kp1 = {-1, 1}, + kp2 = {0, 1}, + kp3 = {1, 1}, + kp4 = {-1, 0}, + kp5 = {0, 0}, + kp6 = {1, 0}, + kp7 = {-1, -1}, + kp8 = {0, -1}, + kp9 = {1, -1}, +} + +-- Clockwise from north, which is how people scan a compass. +local scanOrder = { + {0, -1, "north"}, + {1, -1, "north-east"}, + {1, 0, "east"}, + {1, 1, "south-east"}, + {0, 1, "south"}, + {-1, 1, "south-west"}, + {-1, 0, "west"}, + {-1, -1, "north-west"}, +} + +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 + +local function playerPosition(level) + local actor = level and level.playerActor + if not actor or not actor.x or not actor.y then + return nil + end + return actor.x, actor.y +end + +-------------------------------------------------------------------------------- +-- Probing +-------------------------------------------------------------------------------- + +-- Speak a single tile at an offset from the player. +function surroundings.probe(dx, dy) + local level = getLevel() + local px, py = playerPosition(level) + + if not px then + return + end + + if dx == 0 and dy == 0 then + speech.say("Standing on: " .. describe.tile(level, px, py), true) + return + end + + speech.say(describe.tile(level, px + dx, py + dy, px, py), true) +end + +-- Speak all eight neighbours, clockwise from north. Terse by design: eight +-- descriptions at normal verbosity is a wall of speech. +function surroundings.scan() + local level = getLevel() + local px, py = playerPosition(level) + + if not px then + return + end + + local previousVerbosity = describe.verbosity + describe.verbosity = "terse" + + local parts = {} + for _, entry in ipairs(scanOrder) do + local dx, dy, name = entry[1], entry[2], entry[3] + local text = describe.tile(level, px + dx, py + dy) + parts[#parts + 1] = name .. ", " .. text + end + + describe.verbosity = previousVerbosity + + speech.say(table.concat(parts, ". "), true) +end + +-- Where am I: level name, in-game time, coordinates. +function surroundings.whereAmI() + local level = getLevel() + local px, py = playerPosition(level) + + if not px then + return + end + + local parts = {} + + if level.name then + parts[#parts + 1] = level.name + end + + local ok, world = pcall(require, "models.world") + if ok and world.printableDatetime then + parts[#parts + 1] = world.printableDatetime + end + + parts[#parts + 1] = "position " .. px .. ", " .. py + + speech.say(table.concat(parts, ". "), true) +end + +-------------------------------------------------------------------------------- +-- Key registration +-------------------------------------------------------------------------------- + +function surroundings.registerKeys(hotkeys) + -- Numpad probing is modifier-gated: kp1-kp9 are movement in game, so an + -- unmodified numpad key must keep moving the player. Ctrl is the game's + -- moveCamera modifier but that only acts on direction commands we consume + -- here, so Ctrl+numpad is free. + for scancode, offset in pairs(numpadDirections) do + hotkeys.bind(scancode, "ctrl", function() + surroundings.probe(offset[1], offset[2]) + end) + end + + hotkeys.bind("/", nil, surroundings.scan) + hotkeys.bind(";", nil, surroundings.whereAmI) +end + +return surroundings diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 3c04a03..7a491bf 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -50,16 +50,30 @@ end -- Presses a key and waits long enough for the event to be pumped, handled, and -- for our end-of-frame speech flush to run. -local function press(scancode, note) +local function press(scancode, note, mods) local before = focusText() - print(string.format("[testbed] (%s) press %-8s focus_before=%s %s", - currentState(), scancode, before and ('"' .. before .. '"') or "nil", note or "")) + print(string.format("[testbed] (%s) press %-8s%s focus_before=%s %s", + currentState(), scancode, + mods and (mods.ctrl and " +ctrl" or mods.shift and " +shift" or "") or "", + before and ('"' .. before .. '"') or "nil", note or "")) + + -- Modifier state is read from real hardware, which synthetic events cannot + -- affect -- so override the hotkey layer's modifier probes for this press. + local hotkeys = require("accessibility.hotkeys") + local realCtrl, realShift = hotkeys.isCtrlDown, hotkeys.isShiftDown + + if mods then + hotkeys.isCtrlDown = function() return mods.ctrl == true end + hotkeys.isShiftDown = function() return mods.shift == true end + end love.event.push("keypressed", scancode, scancode, false) love.event.push("keyreleased", scancode, scancode) wait(6) + hotkeys.isCtrlDown, hotkeys.isShiftDown = realCtrl, realShift + local after = focusText() print(string.format("[testbed] -> state=%s focus_after=%s", currentState(), after and ('"' .. after .. '"') or "nil")) @@ -202,6 +216,28 @@ local function scenario() press("[", "review previous") press("]", "review next") press("\\", "repeat current") + + -- Phase 2: surroundings probing (no state change, no game side effects). + press("/", "scan all eight neighbours") + press(";", "where am I") + press("kp8", "probe north", {ctrl = true}) + press("kp6", "probe east", {ctrl = true}) + press("kp5", "probe standing-on", {ctrl = true}) + + -- The game's own examine mode, now narrated via levelCursorPopup. + press("x", "enter examine mode") + wait(10) + check("entered examine mode", currentState() == "ingame_observe", + "got " .. currentState()) + + if currentState() == "ingame_observe" then + press("right", "examine: cursor east") + press("down", "examine: cursor south") + press("escape", "leave examine mode") + wait(10) + check("left examine mode", currentState() == "ingame", + "got " .. currentState()) + end end print(string.format("[testbed] === %d checks, %d failures ===", checks, failures))